From 7a29612f11b01e501081c1a8c216742a6b78273d Mon Sep 17 00:00:00 2001 From: Samuel Abels Date: Thu, 8 Oct 2026 21:21:38 +0200 Subject: [PATCH 001/104] fix: declare camera usage in macOS bundle Info.plist macOS TCC kills the process the moment camera code touches AVFoundation (even just enumerating devices) unless the app bundle's Info.plist declares NSCameraUsageDescription. The key was never part of our PyInstaller BUNDLE config, which is why clicking 'Add new camera' crashed Rayforge 1.12.0 on macOS with no log output (#510, likely also #166). --- Rayforge.spec | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Rayforge.spec b/Rayforge.spec index 0a5546e8c..148e47329 100644 --- a/Rayforge.spec +++ b/Rayforge.spec @@ -83,5 +83,12 @@ app = BUNDLE( info_plist={ **({'CFBundleIconName': 'rayforge'} if _use_car else {}), 'LSMinimumSystemVersion': '12.0', + # Without this key, macOS TCC kills the process the moment any + # camera is opened (even just enumerating devices), so it must + # stay in sync with the camera sources in rayforge/camera. + 'NSCameraUsageDescription': ( + 'Rayforge uses the camera to capture your work surface, ' + 'so it can align your design with the material.' + ), }, ) From 5c424f53a819ab58f4ffd93b9cf1d838c52cd2ae Mon Sep 17 00:00:00 2001 From: Samuel Abels Date: Thu, 8 Oct 2026 21:22:25 +0200 Subject: [PATCH 002/104] fix: wait before first camera read to avoid AVFoundation segfault After granting camera permission, the first grab on macOS could still crash with SIGSEGV inside -[CaptureDelegate grabImageUntilDate:] when the first read raced the capture session startup (#510). OpenCV's AVFoundation backend is flaky when driven from a plain Python thread, and a segfault cannot be caught in Python, so the only defense is to not grab before the session has settled. _capture_has_initial_frame() is the single choke point through which both the device scan and open() validate a freshly opened device; it now waits 0.5s after isOpened() before the first read on macOS only (other platforms keep zero delay and unchanged behavior). The wait is interruptible via the controller's cancel event so shutdown stays prompt. --- rayforge/camera/source/local.py | 34 ++++++++++++++----- tests/camera/test_source.py | 58 +++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 8 deletions(-) diff --git a/rayforge/camera/source/local.py b/rayforge/camera/source/local.py index bfbbb3deb..804785d35 100644 --- a/rayforge/camera/source/local.py +++ b/rayforge/camera/source/local.py @@ -1,4 +1,5 @@ import logging +import sys import threading import time from dataclasses import dataclass @@ -32,6 +33,24 @@ _OPEN_VALIDATION_ATTEMPTS = 3 _OPEN_VALIDATION_DELAY = 0.1 +# Delay observed after a device reports isOpened() == True and before +# its first read() attempt. On macOS this must be nonzero: OpenCV's +# AVFoundation backend can segfault (EXC_BAD_ACCESS inside +# -[CaptureDelegate grabImageUntilDate:]) when the first grab races the +# capture session startup, so the session gets a moment to settle +# before the first frame is grabbed. +_FIRST_READ_WARMUP_DELAY = 0.5 if sys.platform == "darwin" else 0.0 + + +def _interruptible_wait( + delay: float, cancel_event: "threading.Event | None" +) -> bool: + """Wait up to `delay` seconds. Returns True if cancelled.""" + if cancel_event is not None: + return cancel_event.wait(delay) + time.sleep(delay) + return False + def _to_videocapture_arg(target: str) -> int | str: """Convert a scan target for cv2.VideoCapture. @@ -52,6 +71,11 @@ def _capture_has_initial_frame( cancel_event: "threading.Event | None" = None, ) -> bool: """Check whether an opened capture device actually delivers frames.""" + if _FIRST_READ_WARMUP_DELAY > 0 and _interruptible_wait( + _FIRST_READ_WARMUP_DELAY, cancel_event + ): + return False + for attempt in range(attempts): try: ret, frame = cap.read() @@ -61,12 +85,8 @@ def _capture_has_initial_frame( if ret and frame is not None: return True - if attempt < attempts - 1: - if cancel_event is not None: - if cancel_event.wait(delay): - return False - else: - time.sleep(delay) + if attempt < attempts - 1 and _interruptible_wait(delay, cancel_event): + return False return False @@ -142,8 +162,6 @@ def __init__(self, config: "Camera"): @staticmethod def _scan_targets() -> list[str]: - import sys - if sys.platform.startswith("linux"): return _get_linux_scan_targets() return [str(i) for i in range(10)] diff --git a/tests/camera/test_source.py b/tests/camera/test_source.py index 8aa12b7ad..2b96bda1b 100644 --- a/tests/camera/test_source.py +++ b/tests/camera/test_source.py @@ -1,3 +1,4 @@ +import threading from unittest.mock import patch import cv2 @@ -238,6 +239,63 @@ def read(self): assert cap.calls == 3 # type: ignore[attr-defined] +def test_capture_has_initial_frame_warms_up_before_first_read(): + """The first read must be preceded by a warm-up wait. + + Grabbing the first frame immediately after open() can segfault the + AVFoundation backend on macOS (issue #510), so the warm-up delay is + observed before the very first read attempt, and a cancellation + during that warm-up must abort without reading at all. + """ + waits: list[float] = [] + + class MockCapture: + def __init__(self): + self.reads = 0 + + def read(self): + self.reads += 1 + return False, None + + cap: cv2.VideoCapture = MockCapture() # type: ignore[assignment] + with ( + patch("rayforge.camera.source.local._FIRST_READ_WARMUP_DELAY", 0.25), + patch( + "rayforge.camera.source.local._interruptible_wait", + side_effect=lambda d, _e: waits.append(d) or False, + ), + ): + result = _capture_has_initial_frame(cap, attempts=2, delay=0.1) + + assert result is False + assert waits == [0.25, 0.1] + assert cap.reads == 2 # type: ignore[attr-defined] + + +def test_capture_has_initial_frame_aborts_during_warmup_when_cancelled(): + """A cancellation arriving during the warm-up wait must return + without a single read, so shutdown stays prompt even before the + first frame attempt. + """ + + class MockCapture: + def __init__(self): + self.reads = 0 + + def read(self): + self.reads += 1 + return True, None + + cap: cv2.VideoCapture = MockCapture() # type: ignore[assignment] + cancel_event = threading.Event() + cancel_event.set() + with patch("rayforge.camera.source.local._FIRST_READ_WARMUP_DELAY", 5.0): + result = _capture_has_initial_frame(cap, cancel_event=cancel_event) + + assert result is False + assert cap.reads == 0 # type: ignore[attr-defined] + + def test_open_raises_when_device_never_delivers_initial_frame(): """LocalDeviceSource.open() must reject a device that reports isOpened() == True but never successfully reads a frame, instead of From d018888020a00a0bd777615ee496e7a30166470b Mon Sep 17 00:00:00 2001 From: Steve Isaacs Date: Fri, 9 Oct 2026 02:05:53 -0700 Subject: [PATCH 003/104] Updated to use ruida-pa 0.22.0. --- pixi.lock | 49 ++++++++++++++++++++---------------------------- pixi.toml | 2 +- requirements.txt | 2 +- 3 files changed, 22 insertions(+), 31 deletions(-) diff --git a/pixi.lock b/pixi.lock index dfd1f6f29..ed6bb2c56 100644 --- a/pixi.lock +++ b/pixi.lock @@ -1,16 +1,7 @@ version: 7 platforms: - name: linux-64 - virtual-packages: - - __unix=0=0 - - __linux=4.18 - - __glibc=2.28 - - __archspec=0=x86_64 - name: osx-arm64 - virtual-packages: - - __unix=0=0 - - __osx=13.0 - - __archspec=0=m1 environments: appimage: channels: @@ -237,12 +228,12 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/sysroot_linux-64-2.28-h4ee821c_9.conda - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - - pypi: ./ + - pypi: . + - pypi: https://files.pythonhosted.org/packages/03/43/344fbf03470b38c11c334fd1ea9f12212036c93f64887d1f31672a95fd8d/ruida_pa-0.22.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/03/c0/a6d8be9c43e4456fb9597fdf8b5e0ce1f0a5df41503acce6d869536e4e23/websockets-17.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/0c/ef/7cd8d552500d28dc184f55bd80e99fa2c8c07ba4fe97d09ea093bfa0affa/ruida_pa-0.21.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/d4/1152d1c7ab42d8b908be64fd200ddc870dc9d4925e951198702084aa1a7d/linkify_it_py-2.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/16/67/1f0b582e73ce46511f2799cbdcdbdf9ca98f344f2aad42ff5ff5dde0d8fe/narwhals-2.27.0-py3-none-any.whl @@ -470,11 +461,11 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/xorg-libxt-1.3.1-hb001647_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zlib-1.3.2-h8088a28_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hf451053_7.conda - - pypi: ./ + - pypi: . + - pypi: https://files.pythonhosted.org/packages/03/43/344fbf03470b38c11c334fd1ea9f12212036c93f64887d1f31672a95fd8d/ruida_pa-0.22.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/0c/ef/7cd8d552500d28dc184f55bd80e99fa2c8c07ba4fe97d09ea093bfa0affa/ruida_pa-0.21.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/83/c745b210ec49379ebfe627e166b527f44671a1f6ec5e1e219d91caa8964d/fonttools-4.66.1-cp314-cp314-macosx_10_15_universal2.whl - pypi: https://files.pythonhosted.org/packages/13/d4/1152d1c7ab42d8b908be64fd200ddc870dc9d4925e951198702084aa1a7d/linkify_it_py-2.2.0-py3-none-any.whl @@ -915,14 +906,14 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - - pypi: ./ + - pypi: . - pypi: https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/03/43/344fbf03470b38c11c334fd1ea9f12212036c93f64887d1f31672a95fd8d/ruida_pa-0.22.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/03/c0/a6d8be9c43e4456fb9597fdf8b5e0ce1f0a5df41503acce6d869536e4e23/websockets-17.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/0c/ef/7cd8d552500d28dc184f55bd80e99fa2c8c07ba4fe97d09ea093bfa0affa/ruida_pa-0.21.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/d4/1152d1c7ab42d8b908be64fd200ddc870dc9d4925e951198702084aa1a7d/linkify_it_py-2.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/16/67/1f0b582e73ce46511f2799cbdcdbdf9ca98f344f2aad42ff5ff5dde0d8fe/narwhals-2.27.0-py3-none-any.whl @@ -1175,13 +1166,13 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/xorg-libxt-1.3.1-hb001647_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zlib-1.3.2-h8088a28_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hf451053_7.conda - - pypi: ./ + - pypi: . - pypi: https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/03/43/344fbf03470b38c11c334fd1ea9f12212036c93f64887d1f31672a95fd8d/ruida_pa-0.22.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/0c/ef/7cd8d552500d28dc184f55bd80e99fa2c8c07ba4fe97d09ea093bfa0affa/ruida_pa-0.21.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/83/c745b210ec49379ebfe627e166b527f44671a1f6ec5e1e219d91caa8964d/fonttools-4.66.1-cp314-cp314-macosx_10_15_universal2.whl - pypi: https://files.pythonhosted.org/packages/13/d4/1152d1c7ab42d8b908be64fd200ddc870dc9d4925e951198702084aa1a7d/linkify_it_py-2.2.0-py3-none-any.whl @@ -9643,7 +9634,7 @@ packages: - zstd >=1.5.7,<1.6.0a0 size: 433687 timestamp: 1786599629846 -- pypi: ./ +- pypi: . name: rayforge requires_dist: - raydriver==0.3.0 @@ -9666,7 +9657,7 @@ packages: - pyserial==3.5 - pyvips==3.2.0 - pyyaml==6.0.3 - - ruida-pa==0.21.2 + - ruida-pa==0.22.0 - scipy==1.18.1 - semver==3.1.0 - svgelements==1.9.6 @@ -9684,6 +9675,16 @@ packages: name: distlib version: 0.4.3 sha256: 4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b +- pypi: https://files.pythonhosted.org/packages/03/43/344fbf03470b38c11c334fd1ea9f12212036c93f64887d1f31672a95fd8d/ruida_pa-0.22.0-py3-none-any.whl + name: ruida-pa + version: 0.22.0 + sha256: 142c96e252cf8f7190bfc840319e888e3efe60f22be2cc38211a229926bb22a0 + requires_dist: + - bokeh>=3.9 + - pyserial>=3.5 + - textual>=8.2,<9 + - rpyc>=6.0 + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/03/c0/a6d8be9c43e4456fb9597fdf8b5e0ce1f0a5df41503acce6d869536e4e23/websockets-17.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl name: websockets version: '17.2' @@ -9719,16 +9720,6 @@ packages: version: '3.0' sha256: b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/0c/ef/7cd8d552500d28dc184f55bd80e99fa2c8c07ba4fe97d09ea093bfa0affa/ruida_pa-0.21.2-py3-none-any.whl - name: ruida-pa - version: 0.21.2 - sha256: 7a54ff6abb07182a265212261a3162b88acee49e5ceb8e314acd209f3d22062b - requires_dist: - - bokeh>=3.9 - - pyserial>=3.5 - - textual>=0.32 - - rpyc>=6.0 - requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl name: blinker version: 1.9.0 diff --git a/pixi.toml b/pixi.toml index 97974da42..f35a20b38 100644 --- a/pixi.toml +++ b/pixi.toml @@ -41,7 +41,7 @@ patchelf = "==0.19.2" raygeo = "==1.61.0" raydriver = "==0.3.0" rayforge = { editable = true, path = "." } -ruida-pa = "==0.21.2" +ruida-pa = "==0.22.0" aiohttp = "==3.14.3" asyncudp = "==0.11.0" blinker = "==1.9.0" diff --git a/requirements.txt b/requirements.txt index e16bcca8f..5c03767b4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,7 +18,7 @@ pypdf==6.19.0 pyserial==3.5 pyvips==3.2.0 PyYAML==6.0.3 -ruida-pa==0.21.2 +ruida-pa==0.22.0 scipy==1.18.1 semver==3.1.0 svgelements==1.9.6 From dd0a7be7d3a55c917691fb4f661cd0df5d99af33 Mon Sep 17 00:00:00 2001 From: Steve Isaacs Date: Fri, 9 Oct 2026 02:49:49 -0700 Subject: [PATCH 004/104] feature: Add TCP protocol support for Ruida network connections Co-authored-by: medoix <149519+medoix@noreply.github.com> --- rayforge/machine/driver/ruidarpa/README.md | 6 +- .../machine/driver/ruidarpa/rpa_adapter.py | 65 ++++++++- .../driver/ruidarpa/rpa_direct_driver.py | 13 +- tests/machine/driver/ruidarpa/test_adapter.py | 134 ++++++++++++++++++ .../driver/ruidarpa/test_direct_driver.py | 47 ++++++ 5 files changed, 254 insertions(+), 11 deletions(-) diff --git a/rayforge/machine/driver/ruidarpa/README.md b/rayforge/machine/driver/ruidarpa/README.md index d78db94c8..8089b8ff1 100644 --- a/rayforge/machine/driver/ruidarpa/README.md +++ b/rayforge/machine/driver/ruidarpa/README.md @@ -1,6 +1,6 @@ # Ruida RPA Driver -The Ruida RPA driver connects Rayforge to Ruida-based laser controllers. It supports both a direct connection (USB or UDP) and a TUI RPC connection via the Ruida Protocol Analyzer. A machine settings configuration is available as the "Ruida RPA (Connect via Ruida Protocol Analyzer)" device. +The Ruida RPA driver connects Rayforge to Ruida-based laser controllers. It supports both a direct connection (USB, UDP, or TCP) and a TUI RPC connection via the Ruida Protocol Analyzer. A machine settings configuration is available as the "Ruida RPA (Connect via Ruida Protocol Analyzer)" device. ## Installation @@ -15,7 +15,9 @@ ruida-pa is installed from PyPI as part of the default pixi environment. To diag To configure Rayforge to use the ruidarpa driver, select the **Ruida RPA (Connect via Ruida Protocol Analyzer)** device in the machine settings. -Configuring both the **UDP Hostname** and the **USB device** enables automatic swap between connections when cables are connected and disconnected. USB is preferred when both cables are connected because of slightly better performance (no ACK handshake). +Configuring both the **Hostname** and the **USB device** enables automatic swap between connections when cables are connected and disconnected. USB is preferred when both cables are connected because of slightly better performance (no ACK handshake). + +Enabling **TCP protocol** switches the network connection from UDP to TCP; leave it disabled for the default UDP transport. The setting only applies to network connections. The **magic number** setting is used for other controllers. The default of `0x88` will work with many common controllers. The value is entered in hex. diff --git a/rayforge/machine/driver/ruidarpa/rpa_adapter.py b/rayforge/machine/driver/ruidarpa/rpa_adapter.py index cb6cd30ca..7a562b69f 100644 --- a/rayforge/machine/driver/ruidarpa/rpa_adapter.py +++ b/rayforge/machine/driver/ruidarpa/rpa_adapter.py @@ -85,11 +85,16 @@ # Values of the 'connection' setup var: which transport the controller # is reached through. 'auto' mirrors the backend behavior of opening -# USB when available and falling back to UDP. +# USB when available and falling back to the network. CONNECTION_AUTO = "auto" CONNECTION_NETWORK = "network" CONNECTION_USB = "usb" +# Values of the backend start() 'protocol' argument: the network +# transport used when the controller is reached over the network. +PROTOCOL_UDP = "udp" +PROTOCOL_TCP = "tcp" + # Ruida test-hardware speed limits (mm/min base units): 400 mm/s cut, # 600 mm/s travel. Seeded into the machine only while it still holds the # framework defaults (see _UNCONFIGURED_* below). @@ -133,6 +138,21 @@ def _merged_machine_pos( return (new_x, new_y, new_z) +def _resolve_protocol(config: dict[str, Any]) -> str: + """Resolve the network protocol from the setup config. + + TCP is used only when the user enabled ``tcp_proto`` and the + selected connection actually involves the network. USB mode has no + network protocol, so it always resolves to UDP. + """ + if not config.get("tcp_proto", False): + return PROTOCOL_UDP + mode = config.get("connection", CONNECTION_AUTO) + if mode in (CONNECTION_NETWORK, CONNECTION_AUTO): + return PROTOCOL_TCP + return PROTOCOL_UDP + + class RuidaUsbDeviceVar(SerialPortVar): """SerialPortVar whose value is optional (UDP mode needs no USB).""" @@ -194,6 +214,7 @@ def __init__(self, context: RayforgeContext, machine: Machine) -> None: self._tui_mode: bool = False self._rpc_timeout: float = DEFAULT_RPC_TIMEOUT_S self._magic: int | None = None + self._protocol: str = PROTOCOL_UDP self._backend: _RpaBackend | None = None self._listeners_registered: bool = False self._unreachable_warned: bool = False @@ -305,6 +326,26 @@ def get_setup_vars(cls) -> VarSet: "when available and falls back to the network." ), ), + BoolVar( + key="tcp_proto", + label=_("TCP protocol"), + description=_( + "Use TCP instead of UDP for the network " + "connection. Needed by controllers that do not " + "respond over UDP." + ), + default=False, + visible_when=lambda v: ( + v.get("connection", CONNECTION_AUTO) + in (CONNECTION_NETWORK, CONNECTION_AUTO) + ), + ), + # NOTE: 'udp_host' is a misnomer now that TCP is also + # supported — it holds the network host for both + # protocols. It will be renamed to 'network_host' in a + # future release; the rename must be coordinated with + # ruida-pa (RdDriver.start/RPC start also take this + # name) to avoid breaking either side. HostnameVar( key="udp_host", label=_("Hostname"), @@ -482,6 +523,7 @@ def _parse_magic_number(raw_magic: Any) -> int | None: def _setup_implementation(self, **kwargs: Any) -> None: self._config = self._apply_connection_mode(kwargs) self._tui_mode = bool(kwargs.get("tui", False)) + self._protocol = _resolve_protocol(self._config) self._rpc_timeout = self._parse_rpc_timeout( kwargs.get("timeout", DEFAULT_RPC_TIMEOUT_S) @@ -517,13 +559,14 @@ def update_settings(self, **kwargs: Any) -> bool: Arguments that do not affect the connection endpoint (e.g. the RPC timeout) are stored on the instance and apply to subsequent RPCs and reconnection attempts without dropping the connection. - A change of the endpoint or the operating mode requests a - rebuild via the False return value, so the controller reconnects - to the new target. + A change of the endpoint, the operating mode, or the network + protocol requests a rebuild via the False return value, so the + controller reconnects to the new target. """ old_uri = self.resource_uri old_tui_mode = self._tui_mode old_mode = self._config.get("connection", CONNECTION_AUTO) + old_protocol = self._protocol try: timeout = self._parse_rpc_timeout( @@ -536,12 +579,14 @@ def update_settings(self, **kwargs: Any) -> bool: self._config = self._apply_connection_mode(kwargs) self._rpc_timeout = timeout self._magic = magic + self._protocol = _resolve_protocol(self._config) tui_mode = bool(kwargs.get("tui", False)) connection_mode = self._config.get("connection", CONNECTION_AUTO) return ( tui_mode == old_tui_mode and connection_mode == old_mode + and self._protocol == old_protocol and self.resource_uri == old_uri ) @@ -616,7 +661,11 @@ async def _connection_loop(self) -> None: started = await loop.run_in_executor( None, partial( - backend.start, udp_host, usb_device, self._magic + backend.start, + udp_host, + usb_device, + self._magic, + self._protocol, ), ) connected = started @@ -660,7 +709,11 @@ async def _connection_loop(self) -> None: connected = await loop.run_in_executor( None, partial( - driver.start, udp_host, usb_device, self._magic + driver.start, + udp_host, + usb_device, + self._magic, + self._protocol, ), ) if connected: diff --git a/rayforge/machine/driver/ruidarpa/rpa_direct_driver.py b/rayforge/machine/driver/ruidarpa/rpa_direct_driver.py index 521b2dc82..5ceb95ee5 100644 --- a/rayforge/machine/driver/ruidarpa/rpa_direct_driver.py +++ b/rayforge/machine/driver/ruidarpa/rpa_direct_driver.py @@ -43,6 +43,7 @@ def start( udp_host: str | None = None, usb_device: str | None = None, magic: int | None = None, + protocol: str | None = None, ) -> bool: """Start connection to the Ruida controller. @@ -51,9 +52,10 @@ def start( the previously used value. Args: - udp_host: UDP hostname/IP (e.g. '192.168.1.100'). + udp_host: Network hostname/IP (e.g. '192.168.1.100'). usb_device: USB device path. magic: Optional controller magic number (0x00-0xFF). + protocol: Network protocol, 'udp' (default) or 'tcp'. Returns: True if connection succeeded. @@ -62,12 +64,15 @@ def start( kwargs: dict = {"udp_host": udp_host, "usb_device": usb_device} if magic is not None: kwargs["magic"] = magic + if protocol is not None: + kwargs["protocol"] = protocol result = driver.start(**kwargs) if result: _logger.info( - "RPA direct driver connected; udp=%s, usb=%s", + "RPA direct driver connected; udp=%s, usb=%s, protocol=%s", udp_host, usb_device, + protocol, ) else: # The adapter owns the user-facing warning and reports a @@ -75,9 +80,11 @@ def start( # per-attempt warning here would only spam the log during # the reconnect backoff. _logger.debug( - "RPA direct driver failed to connect; udp=%s, usb=%s", + "RPA direct driver failed to connect; udp=%s, usb=%s, " + "protocol=%s", udp_host, usb_device, + protocol, ) return result diff --git a/tests/machine/driver/ruidarpa/test_adapter.py b/tests/machine/driver/ruidarpa/test_adapter.py index 97235e642..ba279cf59 100644 --- a/tests/machine/driver/ruidarpa/test_adapter.py +++ b/tests/machine/driver/ruidarpa/test_adapter.py @@ -59,8 +59,11 @@ DEFAULT_MAX_CUT_SPEED_MMPM, DEFAULT_MAX_TRAVEL_SPEED_MMPM, DEFAULT_RPC_TIMEOUT_S, + PROTOCOL_TCP, + PROTOCOL_UDP, RuidaRPAAdapter, _merged_machine_pos, + _resolve_protocol, _unwrap_mm, ) from rayforge.machine.driver.ruidarpa.rpa_direct_driver import ( @@ -2922,6 +2925,137 @@ async def test_update_settings_same_mode_absorbs( await isolated_machine.shutdown() +class TestTcpProtocolSetup: + """The 'tcp_proto' var selects TCP over the network and resolves to + the backend protocol argument.""" + + def _setup_vars(self, isolated_context, isolated_machine): + adapter = RuidaRPAAdapter(isolated_context, isolated_machine) + return adapter.get_setup_vars() + + def test_tcp_proto_var_is_bool_defaulting_to_udp( + self, isolated_context, isolated_machine + ): + """get_setup_vars must expose tcp_proto defaulting to False.""" + varset = self._setup_vars(isolated_context, isolated_machine) + var = varset.get("tcp_proto") + assert var is not None + assert isinstance(var, BoolVar) + assert var.default is False + + def test_tcp_proto_placed_immediately_after_connection( + self, isolated_context, isolated_machine + ): + """tcp_proto must sit directly after the connection choice.""" + varset = self._setup_vars(isolated_context, isolated_machine) + keys = [var.key for var in varset] + assert keys.index("tcp_proto") == keys.index("connection") + 1 + + def test_tcp_proto_visible_in_network_and_auto( + self, isolated_context, isolated_machine + ): + """The TCP toggle hides in USB mode.""" + varset = self._setup_vars(isolated_context, isolated_machine) + var = varset.get("tcp_proto") + assert var is not None + assert var.is_visible({"connection": CONNECTION_AUTO}) is True + assert var.is_visible({"connection": CONNECTION_NETWORK}) is True + assert var.is_visible({"connection": CONNECTION_USB}) is False + + def test_resolve_protocol_tcp_for_network_and_auto(self): + """tcp_proto=True resolves to TCP in network and auto modes.""" + assert _resolve_protocol({"tcp_proto": True}) == PROTOCOL_TCP + assert ( + _resolve_protocol( + { + "tcp_proto": True, + "connection": CONNECTION_NETWORK, + } + ) + == PROTOCOL_TCP + ) + assert ( + _resolve_protocol( + {"tcp_proto": True, "connection": CONNECTION_AUTO} + ) + == PROTOCOL_TCP + ) + + def test_resolve_protocol_udp_by_default(self): + """Missing or disabled tcp_proto resolves to UDP.""" + assert _resolve_protocol({}) == PROTOCOL_UDP + assert _resolve_protocol({"tcp_proto": False}) == PROTOCOL_UDP + + def test_resolve_protocol_udp_in_usb_mode(self): + """USB mode has no network protocol, so tcp_proto is ignored.""" + assert ( + _resolve_protocol( + {"tcp_proto": True, "connection": CONNECTION_USB} + ) + == PROTOCOL_UDP + ) + + @pytest.mark.asyncio + async def test_setup_stores_resolved_protocol( + self, isolated_context, isolated_machine + ): + """setup() must resolve and store the protocol from tcp_proto.""" + adapter = RuidaRPAAdapter(isolated_context, isolated_machine) + adapter.setup(udp_host="192.168.1.10", tcp_proto=True) + assert adapter._protocol == PROTOCOL_TCP + await adapter.cleanup() + await isolated_machine.shutdown() + + @pytest.mark.asyncio + async def test_update_settings_tcp_proto_change_requests_rebuild( + self, isolated_context, isolated_machine + ): + """Toggling tcp_proto must request a reconnect despite a same URI.""" + adapter = RuidaRPAAdapter(isolated_context, isolated_machine) + adapter.setup(udp_host="192.168.1.10", timeout=1.0) + old_uri = adapter.resource_uri + + accepted = adapter.update_settings( + udp_host="192.168.1.10", timeout=1.0, tcp_proto=True + ) + + assert accepted is False + assert adapter._protocol == PROTOCOL_TCP + assert adapter.resource_uri == old_uri + await adapter.cleanup() + await isolated_machine.shutdown() + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "tui_mode", [DIRECT_MODE, RPC_MODE], ids=["direct", "rpc"] + ) + async def test_connection_loop_passes_protocol( + self, isolated_context, isolated_machine, monkeypatch, tui_mode + ): + """The connect loop must forward the resolved protocol to start().""" + adapter = RuidaRPAAdapter(isolated_context, isolated_machine) + adapter.setup(udp_host="127.0.0.1", tui=tui_mode, tcp_proto=True) + + backend_cls = RpcRdDriver if tui_mode else RpaDirectDriver + backend = Mock(spec=backend_cls) + backend.start.return_value = True + backend.is_connected = True + if tui_mode: + monkeypatch.setattr( + rpa_adapter, "RpcRdDriver", lambda **kw: backend + ) + adapter._backend = backend + + await _run_connect_cycle(adapter, lambda: backend.start.called) + + backend.start.assert_called_once_with( + "127.0.0.1", None, None, PROTOCOL_TCP + ) + + await adapter.cleanup() + await isolated_machine.shutdown() + + class TestPrecheck: """precheck validates the endpoint for the selected mode.""" diff --git a/tests/machine/driver/ruidarpa/test_direct_driver.py b/tests/machine/driver/ruidarpa/test_direct_driver.py index fef7b724a..331503b12 100644 --- a/tests/machine/driver/ruidarpa/test_direct_driver.py +++ b/tests/machine/driver/ruidarpa/test_direct_driver.py @@ -72,6 +72,53 @@ def test_raises_when_disconnected(self, method, args): getattr(driver, method)(*args) +class TestStartDelegation: + """RpaDirectDriver.start forwards connection parameters to RdDriver.""" + + def test_start_forwards_protocol(self): + """A protocol argument must reach the wrapped RdDriver.""" + driver = RpaDirectDriver() + mock_driver = Mock(spec=RdDriver) + mock_driver.start.return_value = True + driver._driver = mock_driver + + result = driver.start("192.168.1.10", None, None, "tcp") + + assert result is True + mock_driver.start.assert_called_once_with( + udp_host="192.168.1.10", usb_device=None, protocol="tcp" + ) + + def test_start_omits_protocol_when_none(self): + """Without a protocol the wrapped driver keeps its previous one.""" + driver = RpaDirectDriver() + mock_driver = Mock(spec=RdDriver) + mock_driver.start.return_value = True + driver._driver = mock_driver + + driver.start("192.168.1.10", None) + + mock_driver.start.assert_called_once_with( + udp_host="192.168.1.10", usb_device=None + ) + + def test_start_forwards_magic_and_protocol_together(self): + """magic and protocol are independent optional kwargs.""" + driver = RpaDirectDriver() + mock_driver = Mock(spec=RdDriver) + mock_driver.start.return_value = True + driver._driver = mock_driver + + driver.start("192.168.1.10", None, 0x88, "udp") + + mock_driver.start.assert_called_once_with( + udp_host="192.168.1.10", + usb_device=None, + magic=0x88, + protocol="udp", + ) + + class TestRequireConnected: """_require_connected gates jog/home on a live session.""" From 8a1a5b8c4e225233c520b2048c7cc52b41f69eba Mon Sep 17 00:00:00 2001 From: Steve Isaacs Date: Fri, 9 Oct 2026 03:12:42 -0700 Subject: [PATCH 005/104] feature: Log MAINBOARD_VERSION and MACHINE_FEATURES controller info Co-authored-by: medoix <149519+medoix@noreply.github.com> --- .../machine/driver/ruidarpa/rpa_adapter.py | 36 ++++++++-- tests/machine/driver/ruidarpa/test_adapter.py | 69 +++++++++++++++++++ 2 files changed, 101 insertions(+), 4 deletions(-) diff --git a/rayforge/machine/driver/ruidarpa/rpa_adapter.py b/rayforge/machine/driver/ruidarpa/rpa_adapter.py index 7a562b69f..b02fcce6d 100644 --- a/rayforge/machine/driver/ruidarpa/rpa_adapter.py +++ b/rayforge/machine/driver/ruidarpa/rpa_adapter.py @@ -121,6 +121,19 @@ def _unwrap_mm(value: object) -> float | None: return None +def _unwrap_field(value: object) -> str: + """Extract the formatted string from a StatusDict field. + + Non-boolean StatusDict fields arrive as ``(decoded_value, + formatted_string)`` tuples in both direct and TUI RPC modes; RPC may + deliver the tuple as a list. Plain values are stringified for + forward compatibility. + """ + if isinstance(value, (list, tuple)): + return str(value[1]) if len(value) > 1 else str(value[0]) + return str(value) + + def _merged_machine_pos( current: Pos, pos_x: float | None, @@ -903,11 +916,12 @@ def _update_machine_pos(self, event: dict[str, Any]) -> None: self.state_changed.send(self, state=self.state) def _log_controller_info(self, event: dict[str, Any]) -> None: - """Log controller identity / bed-size events. + """Log controller identity / capability / bed-size events. - StatusDict only carries keys that changed, so these are rare (card - swap, (re)connect). Values arrive as (value, str_description) - tuples. + Covers ``CARD_ID``, ``BED_SIZE_X``/``BED_SIZE_Y``, + ``MAINBOARD_VERSION``, and ``MACHINE_FEATURES``. StatusDict only + carries keys that changed, so these are rare (card swap, + (re)connect). Values arrive as (value, str_description) tuples. """ card_id = event.get("CARD_ID") if card_id is not None: @@ -932,6 +946,20 @@ def _log_controller_info(self, event: dict[str, Any]) -> None: bed_size_y, extra=self._log_extra("TUI_RPC" if self._tui_mode else "RPA"), ) + mainboard_version = event.get("MAINBOARD_VERSION") + if mainboard_version is not None: + logger.info( + "RPA controller info: mainboard_version=%s", + _unwrap_field(mainboard_version), + extra=self._log_extra("TUI_RPC" if self._tui_mode else "RPA"), + ) + machine_features = event.get("MACHINE_FEATURES") + if machine_features is not None: + logger.info( + "RPA controller info: machine_features=%s", + _unwrap_field(machine_features), + extra=self._log_extra("TUI_RPC" if self._tui_mode else "RPA"), + ) def _on_rpa_error(self, msg: str) -> None: """Handle error events from the Ruida controller.""" diff --git a/tests/machine/driver/ruidarpa/test_adapter.py b/tests/machine/driver/ruidarpa/test_adapter.py index ba279cf59..3633b0357 100644 --- a/tests/machine/driver/ruidarpa/test_adapter.py +++ b/tests/machine/driver/ruidarpa/test_adapter.py @@ -1613,6 +1613,75 @@ async def test_controller_info_events_are_logged( for m in messages ) + @pytest.mark.asyncio + @pytest.mark.parametrize( + "adapter_pair", + [DIRECT_MODE, RPC_MODE], + ids=["direct", "rpc"], + indirect=True, + ) + async def test_mainboard_version_event_is_logged( + self, adapter_pair, caplog + ): + """A MAINBOARD_VERSION event must log the decoded version string.""" + caplog.set_level(logging.INFO, logger=rpa_adapter.logger.name) + adapter, _backend = adapter_pair + adapter._on_rpa_status( + {"MAINBOARD_VERSION": (22155418420, "RDC6442S")} + ) + info_records = [r for r in caplog.records if r.levelno == logging.INFO] + assert len(info_records) == 1 + assert "RPA controller info: mainboard_version=RDC6442S" in ( + info_records[0].message + ) + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "adapter_pair", + [DIRECT_MODE, RPC_MODE], + ids=["direct", "rpc"], + indirect=True, + ) + async def test_machine_features_event_is_logged( + self, adapter_pair, caplog + ): + """A MACHINE_FEATURES event must log the decoded feature string.""" + caplog.set_level(logging.INFO, logger=rpa_adapter.logger.name) + adapter, _backend = adapter_pair + features = "MFeat:Focus, Z Return to Docking, Air Assist Mode: Mode 0" + adapter._on_rpa_status({"MACHINE_FEATURES": (9, features)}) + info_records = [r for r in caplog.records if r.levelno == logging.INFO] + assert len(info_records) == 1 + assert ( + f"RPA controller info: machine_features={features}" + in info_records[0].message + ) + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "adapter_pair", + [DIRECT_MODE, RPC_MODE], + ids=["direct", "rpc"], + indirect=True, + ) + async def test_capability_fields_accept_list_encoding( + self, adapter_pair, caplog + ): + """RPC mode may deliver the field tuple as a list.""" + caplog.set_level(logging.INFO, logger=rpa_adapter.logger.name) + adapter, _backend = adapter_pair + adapter._on_rpa_status( + { + "MAINBOARD_VERSION": [22155418420, "RDC6442S"], + "MACHINE_FEATURES": [9, "MFeat:Focus"], + } + ) + messages = [ + r.message for r in caplog.records if r.levelno == logging.INFO + ] + assert any("mainboard_version=RDC6442S" in m for m in messages) + assert any("machine_features=MFeat:Focus" in m for m in messages) + @pytest.mark.asyncio @pytest.mark.parametrize( "adapter_pair", From f9f78780a8bd4de0cb303548ec89af00d075fa9c Mon Sep 17 00:00:00 2001 From: Steve Isaacs Date: Fri, 9 Oct 2026 03:34:02 -0700 Subject: [PATCH 006/104] feature: Log the ruida-pa version at connection time --- .../machine/driver/ruidarpa/rpa_adapter.py | 25 +++++++++ .../driver/ruidarpa/rpa_direct_driver.py | 9 +++ tests/machine/driver/ruidarpa/test_adapter.py | 55 +++++++++++++++++++ .../driver/ruidarpa/test_direct_driver.py | 6 ++ 4 files changed, 95 insertions(+) diff --git a/rayforge/machine/driver/ruidarpa/rpa_adapter.py b/rayforge/machine/driver/ruidarpa/rpa_adapter.py index b02fcce6d..d7183d7fa 100644 --- a/rayforge/machine/driver/ruidarpa/rpa_adapter.py +++ b/rayforge/machine/driver/ruidarpa/rpa_adapter.py @@ -710,6 +710,23 @@ async def _connection_loop(self) -> None: await loop.run_in_executor( None, backend.set_tail_script, [] ) + version = await loop.run_in_executor( + None, backend.get_version + ) + logger.info( + "RPA controller info: ruidapa_version=%s", + version, + extra=log_extra, + ) + mismatch = await loop.run_in_executor( + None, attrgetter("version_mismatch"), backend + ) + if mismatch: + logger.warning( + "ruida-pa server/client version mismatch; " + "install matching versions", + extra=log_extra, + ) else: backend = self._backend if not isinstance(backend, RpaDirectDriver): @@ -750,6 +767,14 @@ async def _connection_loop(self) -> None: await loop.run_in_executor( None, driver.gluescript.set_tail_script, [] ) + version = await loop.run_in_executor( + None, driver.get_version + ) + logger.info( + "RPA controller info: ruidapa_version=%s", + version, + extra=log_extra, + ) if not connected: raise ConnectionError( diff --git a/rayforge/machine/driver/ruidarpa/rpa_direct_driver.py b/rayforge/machine/driver/ruidarpa/rpa_direct_driver.py index 5ceb95ee5..ca6b65ec3 100644 --- a/rayforge/machine/driver/ruidarpa/rpa_direct_driver.py +++ b/rayforge/machine/driver/ruidarpa/rpa_direct_driver.py @@ -10,6 +10,7 @@ from collections.abc import Callable from typing import TYPE_CHECKING +from rpalib.version import __version__ from ruidadriver.ruida_driver import RdDriver from rayforge.pipeline.encoder.base import EncodedOutput @@ -105,6 +106,14 @@ def is_connected(self) -> bool: """Whether the underlying driver is connected.""" return self._driver is not None and self._driver.is_connected + def get_version(self) -> str: + """Return the ruida-pa version serving this session (direct mode). + + Mirrors ``AppAdapter.get_version()``: direct mode has no remote + process, so the version is the locally installed ruida-pa. + """ + return __version__ + # --- Run control --- def run(self, script: list[str], auto_checksum: bool = False) -> None: diff --git a/tests/machine/driver/ruidarpa/test_adapter.py b/tests/machine/driver/ruidarpa/test_adapter.py index 3633b0357..14bb3a24b 100644 --- a/tests/machine/driver/ruidarpa/test_adapter.py +++ b/tests/machine/driver/ruidarpa/test_adapter.py @@ -136,6 +136,7 @@ async def adapter_pair( backend.start.return_value = True backend.is_connected = True backend.machine_status = {} + backend.get_version.return_value = "0.22.0" if tui_mode: # RpcRdDriver self-connects in its constructor; patch the class # so the connection loop builds our mock instead of a real one. @@ -145,6 +146,9 @@ async def adapter_pair( backend.unregister_status_listener = Mock() backend.unregister_error_listener = Mock() backend.unregister_reply_listener = Mock() + # Spec exposure returns a truthy Mock for this property; default + # it to no-mismatch so connect-path tests stay quiet. + backend.version_mismatch = False adapter._backend = backend yield adapter, backend @@ -2107,6 +2111,57 @@ async def test_direct_connect_clears_wrapped_head_tail(self, adapter_pair): backend.gluescript.set_tail_script.assert_any_call([]) +class TestVersionLogging: + """The ruida-pa version is logged once at connect time.""" + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "adapter_pair", + [DIRECT_MODE, RPC_MODE], + ids=["direct", "rpc"], + indirect=True, + ) + async def test_connection_logs_ruidapa_version(self, adapter_pair, caplog): + """A successful connect must log the serving ruida-pa version.""" + caplog.set_level(logging.INFO, logger=rpa_adapter.logger.name) + adapter, backend = adapter_pair + await _run_connect_cycle(adapter, lambda: adapter._is_connected) + + assert "RPA controller info: ruidapa_version=0.22.0" in caplog.text + backend.get_version.assert_called() + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "adapter_pair", [RPC_MODE], ids=["rpc"], indirect=True + ) + async def test_tui_version_mismatch_warns(self, adapter_pair, caplog): + """A TUI server/client mismatch must log a one-time warning.""" + caplog.set_level(logging.WARNING, logger=rpa_adapter.logger.name) + adapter, backend = adapter_pair + backend.version_mismatch = True + await _run_connect_cycle(adapter, lambda: adapter._is_connected) + + assert "version mismatch" in caplog.text + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "adapter_pair", + [DIRECT_MODE, RPC_MODE], + ids=["direct", "rpc"], + indirect=True, + ) + async def test_connection_info_not_logged_when_disconnected( + self, adapter_pair, caplog + ): + """A failed connect must not log the ruida-pa version.""" + caplog.set_level(logging.INFO, logger=rpa_adapter.logger.name) + adapter, backend = adapter_pair + backend.start.return_value = False + await _run_connect_cycle(adapter, lambda: backend.stop.called) + + assert "ruidapa_version=" not in caplog.text + + class TestHealthPoll: """Poll health per mode: both modes read backend ``is_connected``.""" diff --git a/tests/machine/driver/ruidarpa/test_direct_driver.py b/tests/machine/driver/ruidarpa/test_direct_driver.py index 331503b12..f22fbcd9b 100644 --- a/tests/machine/driver/ruidarpa/test_direct_driver.py +++ b/tests/machine/driver/ruidarpa/test_direct_driver.py @@ -10,6 +10,7 @@ from unittest.mock import Mock import pytest +from rpalib.version import __version__ from ruidadriver.ruida_driver import RdDriver from rayforge.machine.driver.ruidarpa.rpa_direct_driver import RpaDirectDriver @@ -118,6 +119,11 @@ def test_start_forwards_magic_and_protocol_together(self): protocol="udp", ) + def test_get_version_returns_installed_ruida_pa(self): + """get_version reflects the locally installed ruida-pa version.""" + driver = RpaDirectDriver() + assert driver.get_version() == __version__ + class TestRequireConnected: """_require_connected gates jog/home on a live session.""" From 27c4bb7de7a0c80a6deaac496a9f9a2b253eb3d1 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Fri, 9 Oct 2026 12:58:27 +0200 Subject: [PATCH 007/104] feat(import): import GIF, TIFF and WebP bitmaps Add GifImporter, TiffImporter and WebpImporter on a shared bitmap importer that loads any libvips-readable image by content and traces it like PNG. Animated GIFs and multi-page TIFFs import their first frame or page. A TIFF's stored resolution sets the physical size; images without one fall back to 96 DPI, matching the scan step. A single BitmapRenderer renders the source asset of all three formats. Also fixes the PNG row of the raster table in the import docs, which listed the .webp extension. Refs #517 --- CHANGELOG.md | 2 + rayforge/image/__init__.py | 6 + rayforge/image/bitmap/__init__.py | 0 rayforge/image/bitmap/importer.py | 177 ++++++++++++++ rayforge/image/bitmap/renderer.py | 39 +++ rayforge/locale/de/LC_MESSAGES/rayforge.po | 5 + rayforge/locale/en/LC_MESSAGES/rayforge.po | 5 + rayforge/locale/es/LC_MESSAGES/rayforge.po | 5 + rayforge/locale/fr/LC_MESSAGES/rayforge.po | 5 + rayforge/locale/hi/LC_MESSAGES/rayforge.po | 5 + rayforge/locale/pt/LC_MESSAGES/rayforge.po | 5 + rayforge/locale/rayforge.pot | 5 + rayforge/locale/uk/LC_MESSAGES/rayforge.po | 5 + rayforge/locale/zh_CN/LC_MESSAGES/rayforge.po | 5 + tests/image/bitmap/test_bitmap_formats.py | 222 ++++++++++++++++++ website/docs/files/formats.md | 16 ++ website/docs/files/importing.md | 7 +- .../current/files/formats.md | 42 +++- .../current/files/importing.md | 7 +- .../current/files/formats.md | 16 ++ .../current/files/importing.md | 7 +- .../current/files/formats.md | 43 +++- .../current/files/importing.md | 7 +- .../current/files/formats.md | 16 ++ .../current/files/importing.md | 7 +- .../current/files/formats.md | 42 +++- .../current/files/importing.md | 15 +- .../current/files/formats.md | 16 ++ .../current/files/importing.md | 7 +- .../current/files/formats.md | 16 ++ .../current/files/importing.md | 7 +- 31 files changed, 703 insertions(+), 59 deletions(-) create mode 100644 rayforge/image/bitmap/__init__.py create mode 100644 rayforge/image/bitmap/importer.py create mode 100644 rayforge/image/bitmap/renderer.py create mode 100644 tests/image/bitmap/test_bitmap_formats.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e29ae223a..a2c135985 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Import: GIF, TIFF and WebP bitmaps, traced like PNG. Animated GIFs + and multi-page TIFFs import their first frame or page (#517) - Sketcher: boolean operations. Union, Difference, Intersection and Exclude bake the selected closed regions into multi-ring polygons (outer contours counter-clockwise, hole contours clockwise), so diff --git a/rayforge/image/__init__.py b/rayforge/image/__init__.py index def13cfd8..7f6166b67 100644 --- a/rayforge/image/__init__.py +++ b/rayforge/image/__init__.py @@ -13,6 +13,8 @@ ImporterFeature, ) from .base_renderer import Renderer +from .bitmap.importer import GifImporter, TiffImporter, WebpImporter +from .bitmap.renderer import BITMAP_RENDERER from .bmp.importer import BmpImporter from .bmp.renderer import BMP_RENDERER from .dxf.exporter import GeometryDxfExporter @@ -224,6 +226,7 @@ def import_file( _RENDERERS = [ + BITMAP_RENDERER, BMP_RENDERER, DXF_RENDERER, LIGHTBURN_RENDERER, @@ -251,6 +254,7 @@ def get_renderer_for_asset(asset_type: str) -> Renderer | None: "DxfImporter", "GeometryDxfExporter", "GeometrySvgExporter", + "GifImporter", "ImportManifest", "ImportPayload", "ImportResult", @@ -263,6 +267,8 @@ def get_renderer_for_asset(asset_type: str) -> Renderer | None: "PngImporter", "RuidaImporter", "SvgImporter", + "TiffImporter", + "WebpImporter", "exporter_registry", "get_renderer_for_asset", "import_file", diff --git a/rayforge/image/bitmap/__init__.py b/rayforge/image/bitmap/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/rayforge/image/bitmap/importer.py b/rayforge/image/bitmap/importer.py new file mode 100644 index 000000000..d4b80a990 --- /dev/null +++ b/rayforge/image/bitmap/importer.py @@ -0,0 +1,177 @@ +import logging +import warnings +from gettext import gettext as _ +from pathlib import Path +from typing import ClassVar + +with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + import pyvips + +from raygeo.geo import Geometry + +from ...core.source_asset import SourceAsset +from ...core.vectorization_spec import TraceSpec, VectorizationSpec +from .. import util +from ..base_importer import Importer, ImporterFeature +from ..engine import NormalizationEngine +from ..structures import ( + ImportManifest, + LayerGeometry, + ParsingResult, + VectorizationResult, +) +from ..tracing import trace_surface +from .renderer import BITMAP_RENDERER, load_first_page + +logger = logging.getLogger(__name__) + + +class _BitmapImporter(Importer): + """ + Imports any bitmap format that libvips can load by content, such as + GIF, TIFF and WebP. Multi-page files (animated GIFs, multi-page + TIFFs) import their first page. The bitmap is traced into vectors. + """ + + image_format: ClassVar[str] = "" + features: ClassVar[set[ImporterFeature]] = {ImporterFeature.BITMAP_TRACING} + + def __init__(self, data: bytes, source_file: Path | None = None): + super().__init__(data, source_file) + self._image: pyvips.Image | None = None + + def _load(self) -> pyvips.Image | None: + try: + return load_first_page(self.raw_data) + except pyvips.Error as e: + logger.warning( + f"{self.image_format} load failed for " + f"{self.source_file.name}: {e}" + ) + self.add_error( + _("Could not read the {format} image: {error}").format( + format=self.image_format, error=e + ) + ) + return None + + def scan(self) -> ImportManifest: + image = self._load() + if image is None: + return ImportManifest( + title=self.source_file.name, errors=self._errors + ) + return ImportManifest( + title=self.source_file.name, + natural_size_mm=util.get_physical_size_mm(image), + warnings=self._warnings, + errors=self._errors, + ) + + def create_source_asset(self, parse_result: ParsingResult) -> SourceAsset: + metadata = util.extract_vips_metadata(self._image) + metadata["image_format"] = self.image_format + _ignored1, _ignored2, w_px, h_px = parse_result.document_bounds + return SourceAsset( + source_file=self.source_file, + original_data=self.raw_data, + renderer=BITMAP_RENDERER, + metadata=metadata, + thumbnail_data=self._render_thumbnail_from_vips(self._image), + width_px=int(w_px), + height_px=int(h_px), + width_mm=w_px * parse_result.native_unit_to_mm, + height_mm=h_px * parse_result.native_unit_to_mm, + ) + + def vectorize( + self, + parse_result: ParsingResult, + spec: VectorizationSpec, + ) -> VectorizationResult: + """Phase 3: Generate vector geometry by tracing the bitmap.""" + assert self._image is not None, "parse() must be called first" + if not isinstance(spec, TraceSpec): + raise TypeError(f"{type(self).__name__} only supports TraceSpec") + + normalized_image = util.normalize_to_rgba(self._image) + if not normalized_image: + self.add_error(_("Failed to process image data.")) + return VectorizationResult( + geometries_by_layer={}, source_parse_result=parse_result + ) + + surface = util.vips_rgba_to_cairo_surface(normalized_image) + merged_geo = Geometry() + for geo in trace_surface(surface, spec): + merged_geo.extend(geo) + + return VectorizationResult( + geometries_by_layer={None: merged_geo}, + source_parse_result=parse_result, + ) + + def parse(self) -> ParsingResult | None: + """Phase 2: Load the first page and extract geometric facts.""" + self._image = self._load() + if self._image is None: + return None + + image = self._image + document_bounds = (0.0, 0.0, float(image.width), float(image.height)) + native_unit_to_mm, _mm_per_px_y = util.get_mm_per_pixel(image) + + x, _y, w, h = document_bounds + world_frame = ( + x * native_unit_to_mm, + 0.0, + w * native_unit_to_mm, + h * native_unit_to_mm, + ) + temp_result = ParsingResult( + document_bounds=document_bounds, + native_unit_to_mm=native_unit_to_mm, + is_y_down=True, + layers=[], + world_frame_of_reference=world_frame, + background_world_transform=None, # type: ignore + ) + bg_item = NormalizationEngine.calculate_layout_item( + document_bounds, temp_result + ) + return ParsingResult( + document_bounds=document_bounds, + native_unit_to_mm=native_unit_to_mm, + is_y_down=True, + layers=[ + LayerGeometry( + layer_id="__default__", + name="__default__", + content_bounds=document_bounds, + ) + ], + world_frame_of_reference=world_frame, + background_world_transform=bg_item.world_matrix, + ) + + +class GifImporter(_BitmapImporter): + label = "GIF files" + mime_types = ("image/gif",) + extensions = (".gif",) + image_format = "GIF" + + +class TiffImporter(_BitmapImporter): + label = "TIFF files" + mime_types = ("image/tiff",) + extensions = (".tif", ".tiff") + image_format = "TIFF" + + +class WebpImporter(_BitmapImporter): + label = "WebP files" + mime_types = ("image/webp",) + extensions = (".webp",) + image_format = "WebP" diff --git a/rayforge/image/bitmap/renderer.py b/rayforge/image/bitmap/renderer.py new file mode 100644 index 000000000..c2083b470 --- /dev/null +++ b/rayforge/image/bitmap/renderer.py @@ -0,0 +1,39 @@ +import warnings + +from ..base_renderer import RasterRenderer + +with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + import pyvips + + +def load_first_page(data: bytes) -> pyvips.Image: + """ + Loads a bitmap of any format libvips detects by content. Animated + and multi-page files yield their first page. + + Raises: + pyvips.Error: If the data is not a readable image. + """ + return pyvips.Image.new_from_buffer(data, "", access=pyvips.Access.RANDOM) + + +class BitmapRenderer(RasterRenderer): + """Renders GIF, TIFF, WebP and other libvips-readable bitmaps.""" + + def render_base_image( + self, + data: bytes, + width: int, + height: int, + **kwargs, + ) -> pyvips.Image | None: + if not data: + return None + try: + return load_first_page(data) + except pyvips.Error: + return None + + +BITMAP_RENDERER = BitmapRenderer() diff --git a/rayforge/locale/de/LC_MESSAGES/rayforge.po b/rayforge/locale/de/LC_MESSAGES/rayforge.po index c24892115..fb1cdc1e2 100644 --- a/rayforge/locale/de/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/de/LC_MESSAGES/rayforge.po @@ -10470,3 +10470,8 @@ msgstr "Bayer 4" #: rayforge/image/dither.py msgid "Bayer 8" msgstr "Bayer 8" + +#: rayforge/image/bitmap/importer.py +#, python-brace-format +msgid "Could not read the {format} image: {error}" +msgstr "Das {format}-Bild konnte nicht gelesen werden: {error}" diff --git a/rayforge/locale/en/LC_MESSAGES/rayforge.po b/rayforge/locale/en/LC_MESSAGES/rayforge.po index a8c6a76cf..54f1b033c 100644 --- a/rayforge/locale/en/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/en/LC_MESSAGES/rayforge.po @@ -9828,3 +9828,8 @@ msgstr "" #: rayforge/image/dither.py msgid "Bayer 8" msgstr "" + +#: rayforge/image/bitmap/importer.py +#, python-brace-format +msgid "Could not read the {format} image: {error}" +msgstr "" diff --git a/rayforge/locale/es/LC_MESSAGES/rayforge.po b/rayforge/locale/es/LC_MESSAGES/rayforge.po index 9a798cfc7..bf225fc19 100644 --- a/rayforge/locale/es/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/es/LC_MESSAGES/rayforge.po @@ -10444,3 +10444,8 @@ msgstr "Bayer 4" #: rayforge/image/dither.py msgid "Bayer 8" msgstr "Bayer 8" + +#: rayforge/image/bitmap/importer.py +#, python-brace-format +msgid "Could not read the {format} image: {error}" +msgstr "No se pudo leer la imagen {format}: {error}" diff --git a/rayforge/locale/fr/LC_MESSAGES/rayforge.po b/rayforge/locale/fr/LC_MESSAGES/rayforge.po index a39af8cae..7370b7b3c 100644 --- a/rayforge/locale/fr/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/fr/LC_MESSAGES/rayforge.po @@ -10491,3 +10491,8 @@ msgstr "Bayer 4" #: rayforge/image/dither.py msgid "Bayer 8" msgstr "Bayer 8" + +#: rayforge/image/bitmap/importer.py +#, python-brace-format +msgid "Could not read the {format} image: {error}" +msgstr "Impossible de lire l'image {format} : {error}" diff --git a/rayforge/locale/hi/LC_MESSAGES/rayforge.po b/rayforge/locale/hi/LC_MESSAGES/rayforge.po index a06a87e8e..f7bfe7642 100644 --- a/rayforge/locale/hi/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/hi/LC_MESSAGES/rayforge.po @@ -10150,3 +10150,8 @@ msgstr "Bayer 4" #: rayforge/image/dither.py msgid "Bayer 8" msgstr "Bayer 8" + +#: rayforge/image/bitmap/importer.py +#, python-brace-format +msgid "Could not read the {format} image: {error}" +msgstr "{format} छवि पढ़ी नहीं जा सकी: {error}" diff --git a/rayforge/locale/pt/LC_MESSAGES/rayforge.po b/rayforge/locale/pt/LC_MESSAGES/rayforge.po index d5f66bd3f..9bc997263 100644 --- a/rayforge/locale/pt/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/pt/LC_MESSAGES/rayforge.po @@ -10396,3 +10396,8 @@ msgstr "Bayer 4" #: rayforge/image/dither.py msgid "Bayer 8" msgstr "Bayer 8" + +#: rayforge/image/bitmap/importer.py +#, python-brace-format +msgid "Could not read the {format} image: {error}" +msgstr "Não foi possível ler a imagem {format}: {error}" diff --git a/rayforge/locale/rayforge.pot b/rayforge/locale/rayforge.pot index 61ccf8d71..440c7e7e8 100644 --- a/rayforge/locale/rayforge.pot +++ b/rayforge/locale/rayforge.pot @@ -9829,3 +9829,8 @@ msgstr "" #: rayforge/image/dither.py msgid "Bayer 8" msgstr "" + +#: rayforge/image/bitmap/importer.py +#, python-brace-format +msgid "Could not read the {format} image: {error}" +msgstr "" diff --git a/rayforge/locale/uk/LC_MESSAGES/rayforge.po b/rayforge/locale/uk/LC_MESSAGES/rayforge.po index 2f0df73fe..5f7256e70 100644 --- a/rayforge/locale/uk/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/uk/LC_MESSAGES/rayforge.po @@ -10356,3 +10356,8 @@ msgstr "Bayer 4" #: rayforge/image/dither.py msgid "Bayer 8" msgstr "Bayer 8" + +#: rayforge/image/bitmap/importer.py +#, python-brace-format +msgid "Could not read the {format} image: {error}" +msgstr "Не вдалося прочитати зображення {format}: {error}" diff --git a/rayforge/locale/zh_CN/LC_MESSAGES/rayforge.po b/rayforge/locale/zh_CN/LC_MESSAGES/rayforge.po index 9a23e3ab0..790f98ac9 100644 --- a/rayforge/locale/zh_CN/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/zh_CN/LC_MESSAGES/rayforge.po @@ -9957,3 +9957,8 @@ msgstr "Bayer 4" #: rayforge/image/dither.py msgid "Bayer 8" msgstr "Bayer 8" + +#: rayforge/image/bitmap/importer.py +#, python-brace-format +msgid "Could not read the {format} image: {error}" +msgstr "无法读取 {format} 图像:{error}" diff --git a/tests/image/bitmap/test_bitmap_formats.py b/tests/image/bitmap/test_bitmap_formats.py new file mode 100644 index 000000000..43f1bab9a --- /dev/null +++ b/tests/image/bitmap/test_bitmap_formats.py @@ -0,0 +1,222 @@ +import io +import warnings +from pathlib import Path +from typing import cast + +import numpy as np +import pytest +from PIL import Image + +with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + import pyvips + +from rayforge.core.source_asset import SourceAsset +from rayforge.core.vectorization_spec import TraceSpec +from rayforge.core.workpiece import WorkPiece +from rayforge.image import import_file, importer_registry +from rayforge.image.base_importer import ImporterFeature +from rayforge.image.bitmap.importer import ( + GifImporter, + TiffImporter, + WebpImporter, +) +from rayforge.image.bitmap.renderer import BITMAP_RENDERER +from rayforge.image.registry import renderer_registry + +WIDTH_PX = 120 +HEIGHT_PX = 80 + + +def _square_image(offset_x: int = 30, bands: int = 3) -> pyvips.Image: + """A white image with a solid black square.""" + pixels = np.full((HEIGHT_PX, WIDTH_PX, bands), 255, dtype=np.uint8) + pixels[20:60, offset_x : offset_x + 40, :] = 0 + image = pyvips.Image.new_from_array(pixels, interpretation="srgb") + if bands == 1: + image = image.copy(interpretation="b-w") + return image + + +def _square_pixels(offset_x: int = 30) -> np.ndarray: + pixels = np.full((HEIGHT_PX, WIDTH_PX), 255, dtype=np.uint8) + pixels[20:60, offset_x : offset_x + 40] = 0 + return pixels + + +def _gif_bytes() -> bytes: + buf = io.BytesIO() + Image.fromarray(_square_pixels()).save(buf, format="GIF") + return buf.getvalue() + + +def _animated_gif_bytes() -> bytes: + frames = [Image.fromarray(_square_pixels(x)) for x in (10, 70)] + buf = io.BytesIO() + frames[0].save( + buf, + format="GIF", + save_all=True, + append_images=frames[1:], + duration=100, + loop=0, + ) + return buf.getvalue() + + +def _tiff_bytes(dpi: float = 254.0, sixteen_bit: bool = False) -> bytes: + image = _square_image(bands=1) + if sixteen_bit: + image = (image.cast("ushort") * 257).copy(interpretation="grey16") + image = image.copy(xres=dpi / 25.4, yres=dpi / 25.4) + return image.tiffsave_buffer(resunit="inch") + + +def _webp_bytes() -> bytes: + return _square_image().webpsave_buffer(lossless=True) + + +FORMATS = [ + pytest.param(GifImporter, _gif_bytes, "logo.gif", id="gif"), + pytest.param(TiffImporter, _tiff_bytes, "logo.tif", id="tiff"), + pytest.param(WebpImporter, _webp_bytes, "logo.webp", id="webp"), +] + + +class TestRegistration: + @pytest.mark.parametrize( + "extension, importer_cls", + [ + (".gif", GifImporter), + (".tif", TiffImporter), + (".tiff", TiffImporter), + (".webp", WebpImporter), + ], + ) + def test_extension_lookup(self, extension, importer_cls): + assert importer_registry.get_by_extension(extension) is importer_cls + + @pytest.mark.parametrize( + "mime_type, importer_cls", + [ + ("image/gif", GifImporter), + ("image/tiff", TiffImporter), + ("image/webp", WebpImporter), + ], + ) + def test_mime_lookup(self, mime_type, importer_cls): + assert importer_registry.get_by_mime_type(mime_type) is importer_cls + + def test_renderer_is_registered(self): + name = type(BITMAP_RENDERER).__name__ + assert renderer_registry.get(name) is BITMAP_RENDERER + + @pytest.mark.parametrize( + "importer_cls", [GifImporter, TiffImporter, WebpImporter] + ) + def test_features(self, importer_cls): + assert importer_cls.features == {ImporterFeature.BITMAP_TRACING} + + +class TestImport: + @pytest.mark.parametrize("importer_cls, make_data, name", FORMATS) + def test_scan_reports_size(self, importer_cls, make_data, name): + manifest = importer_cls(make_data(), Path(name)).scan() + assert manifest.title == name + assert manifest.errors == [] + assert manifest.natural_size_mm is not None + width_mm, height_mm = manifest.natural_size_mm + assert width_mm / height_mm == pytest.approx(WIDTH_PX / HEIGHT_PX) + + @pytest.mark.parametrize("importer_cls, make_data, name", FORMATS) + def test_trace_creates_workpiece(self, importer_cls, make_data, name): + data = make_data() + result = importer_cls(data, Path(name)).get_doc_items(TraceSpec()) + assert result is not None and result.payload is not None + source = result.payload.source + assert isinstance(source, SourceAsset) + assert source.renderer is BITMAP_RENDERER + assert source.original_data == data + assert (source.width_px, source.height_px) == (WIDTH_PX, HEIGHT_PX) + assert source.metadata["_importer_class"] == importer_cls.__name__ + wp = cast(WorkPiece, result.payload.items[0]) + assert wp.boundaries is not None + assert not wp.boundaries.is_empty() + + @pytest.mark.parametrize("importer_cls, make_data, name", FORMATS) + def test_invalid_data_reports_error(self, importer_cls, make_data, name): + importer = importer_cls(b"not an image", Path(name)) + manifest = importer.scan() + assert manifest.errors + result = importer_cls(b"not an image", Path(name)).get_doc_items( + TraceSpec() + ) + assert result is not None + assert result.payload is None + assert result.errors + + @pytest.mark.parametrize("importer_cls, make_data, name", FORMATS) + def test_import_file_by_path( + self, tmp_path, importer_cls, make_data, name + ): + path = tmp_path / name + path.write_bytes(make_data()) + payload = import_file(path, vectorization_spec=TraceSpec()) + assert payload is not None + assert payload.source.metadata["_importer_class"] == ( + importer_cls.__name__ + ) + + def test_tiff_resolution_sets_physical_size(self): + manifest = TiffImporter(_tiff_bytes(dpi=254.0), Path("a.tif")).scan() + assert manifest.natural_size_mm == pytest.approx((12.0, 8.0)) + + @pytest.mark.parametrize("importer_cls, make_data, name", FORMATS[::2]) + def test_missing_resolution_falls_back_to_96_dpi( + self, importer_cls, make_data, name + ): + importer = importer_cls(make_data(), Path(name)) + expected = (WIDTH_PX * 25.4 / 96.0, HEIGHT_PX * 25.4 / 96.0) + assert importer.scan().natural_size_mm == pytest.approx(expected) + result = importer_cls(make_data(), Path(name)).get_doc_items( + TraceSpec() + ) + assert result is not None and result.payload is not None + source = result.payload.source + assert (source.width_mm, source.height_mm) == pytest.approx(expected) + + def test_sixteen_bit_tiff_is_traced(self): + data = _tiff_bytes(sixteen_bit=True) + result = TiffImporter(data, Path("deep.tif")).get_doc_items( + TraceSpec() + ) + assert result is not None and result.payload is not None + wp = cast(WorkPiece, result.payload.items[0]) + assert wp.boundaries is not None + assert not wp.boundaries.is_empty() + + def test_animated_gif_uses_first_frame(self): + data = _animated_gif_bytes() + result = GifImporter(data, Path("anim.gif")).get_doc_items(TraceSpec()) + assert result is not None and result.payload is not None + source = result.payload.source + assert (source.width_px, source.height_px) == (WIDTH_PX, HEIGHT_PX) + + +class TestRenderer: + @pytest.mark.parametrize("importer_cls, make_data, name", FORMATS) + def test_renders_base_image(self, importer_cls, make_data, name): + image = BITMAP_RENDERER.render_base_image(make_data(), 0, 0) + assert image is not None + assert (image.width, image.height) == (WIDTH_PX, HEIGHT_PX) + + def test_renders_first_gif_frame_only(self): + image = BITMAP_RENDERER.render_base_image(_animated_gif_bytes(), 0, 0) + assert image is not None + assert (image.width, image.height) == (WIDTH_PX, HEIGHT_PX) + assert image.getpoint(20, 40)[0] < 50 + assert image.getpoint(90, 40)[0] > 200 + + def test_invalid_data_renders_nothing(self): + assert BITMAP_RENDERER.render_base_image(b"junk", 10, 10) is None + assert BITMAP_RENDERER.render_base_image(b"", 10, 10) is None diff --git a/website/docs/files/formats.md b/website/docs/files/formats.md index 6ceadb063..1620bf0c0 100644 --- a/website/docs/files/formats.md +++ b/website/docs/files/formats.md @@ -23,6 +23,9 @@ capabilities, limitations, and recommendations. | **PNG** | Raster | ✓ Trace | – | Photos, images | | **JPEG** | Raster | ✓ Trace | – | Photos | | **BMP** | Raster | ✓ Trace | – | Simple graphics | +| **GIF** | Raster | ✓ Trace | – | Simple graphics, animations | +| **TIFF** | Raster | ✓ Trace | – | Scans, print artwork | +| **WebP** | Raster | ✓ Trace | – | Web images | | **RFS** | Sketch | ✓ Direct | ✓ Object export | Parametric sketches | | **G-code** | Control | – | ✓ Primary | Machine output | | **Rayforge Project** | Project | ✓ | ✓ | Save/load projects | @@ -287,6 +290,19 @@ supported --- +### GIF, TIFF and WebP + +**Extension:** `.gif`, `.tif`, `.tiff`, `.webp` **MIME Type:** `image/gif`, `image/tiff`, +`image/webp` **Import:** Trace to vectors **Export:** Not supported + +These formats are traced in the same way as PNG. + +- **Animated GIFs** and **multi-page TIFFs** import only their first frame or page +- **TIFF** files usually store their print resolution, which sets the physical size +- Images without a resolution are imported at 96 DPI + +--- + ## Related Pages - [Importing Files](importing) - How to import each format diff --git a/website/docs/files/importing.md b/website/docs/files/importing.md index 1181617f2..27bd47687 100644 --- a/website/docs/files/importing.md +++ b/website/docs/files/importing.md @@ -25,9 +25,12 @@ import files and optimize them for best results. | Format | Extension | Import Method | Best For | | -------- | --------------- | ---------------- | -------------------------------- | -| **PNG** | `.webp` | Trace to vectors | Photos, images with transparency | +| **PNG** | `.png` | Trace to vectors | Photos, images with transparency | | **JPEG** | `.jpg`, `.jpeg` | Trace to vectors | Photos, continuous-tone images | | **BMP** | `.bmp` | Trace to vectors | Simple graphics, screenshots | +| **GIF** | `.gif` | Trace to vectors | Simple graphics, animations | +| **TIFF** | `.tif`, `.tiff` | Trace to vectors | Scans, print artwork | +| **WebP** | `.webp` | Trace to vectors | Web images | :::note[Raster Import] @@ -351,7 +354,7 @@ settings. --- -## Raster Image Import (PNG, JPG, BMP) +## Raster Image Import (PNG, JPG, BMP, GIF, TIFF, WebP) Raster images are **traced** to create vector paths using the import dialog. diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/files/formats.md b/website/i18n/de/docusaurus-plugin-content-docs/current/files/formats.md index 1889b7695..8a836e6fe 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/files/formats.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/files/formats.md @@ -7,19 +7,22 @@ einschließlich Fähigkeiten, Einschränkungen und Empfehlungen. ### Schnellreferenz -| Format | Typ | Import | Export | Empfohlene Verwendung | -| -------------------- | --------- | ------------------ | --------------- | -------------------------- | -| **SVG** | Vektor | ✓ Direkt / Tracing | ✓ Objekt-Export | Primäres Design-Format | -| **DXF** | Vektor | ✓ Direkt | ✓ Objekt-Export | CAD-Datenaustausch | -| **PDF** | Gemischt | ✓ Direkt / Tracing | – | Dokumente mit Vektorinhalt | -| **LightBurn** | Vektor | ✓ Direkt | – | LightBurn-Projekte | -| **Ruida** | Vektor | ✓ Direkt | – | Ruida-Aufgabendateien | -| **PNG** | Raster | ✓ Tracing | – | Fotos, Bilder | -| **JPEG** | Raster | ✓ Tracing | – | Fotos | -| **BMP** | Raster | ✓ Tracing | – | Einfache Grafiken | -| **RFS** | Skizze | ✓ Direkt | ✓ Objekt-Export | Parametrische Skizzen | -| **G-Code** | Steuerung | – | ✓ Primär | Maschinenausgabe | -| **Rayforge-Projekt** | Projekt | ✓ | ✓ | Projekte speichern/laden | +| Format | Typ | Import | Export | Empfohlene Verwendung | +| -------------------- | --------- | ------------------ | --------------- | ------------------------------ | +| **SVG** | Vektor | ✓ Direkt / Tracing | ✓ Objekt-Export | Primäres Design-Format | +| **DXF** | Vektor | ✓ Direkt | ✓ Objekt-Export | CAD-Datenaustausch | +| **PDF** | Gemischt | ✓ Direkt / Tracing | – | Dokumente mit Vektorinhalt | +| **LightBurn** | Vektor | ✓ Direkt | – | LightBurn-Projekte | +| **Ruida** | Vektor | ✓ Direkt | – | Ruida-Aufgabendateien | +| **PNG** | Raster | ✓ Tracing | – | Fotos, Bilder | +| **JPEG** | Raster | ✓ Tracing | – | Fotos | +| **BMP** | Raster | ✓ Tracing | – | Einfache Grafiken | +| **GIF** | Raster | ✓ Tracing | – | Einfache Grafiken, Animationen | +| **TIFF** | Raster | ✓ Tracing | – | Scans, Druckvorlagen | +| **WebP** | Raster | ✓ Tracing | – | Webbilder | +| **RFS** | Skizze | ✓ Direkt | ✓ Objekt-Export | Parametrische Skizzen | +| **G-Code** | Steuerung | – | ✓ Primär | Maschinenausgabe | +| **Rayforge-Projekt** | Projekt | ✓ | ✓ | Projekte speichern/laden | --- @@ -282,6 +285,19 @@ unterstützt --- +### GIF, TIFF und WebP + +**Erweiterung:** `.gif`, `.tif`, `.tiff`, `.webp` **MIME-Typ:** `image/gif`, `image/tiff`, +`image/webp` **Import:** Tracing zu Vektoren **Export:** Nicht unterstützt + +Diese Formate werden genauso nachgezeichnet wie PNG. + +- **Animierte GIFs** und **mehrseitige TIFFs** importieren nur ihr erstes Bild bzw. ihre erste Seite +- **TIFF**-Dateien speichern meist ihre Druckauflösung, die die physische Größe bestimmt +- Bilder ohne Auflösung werden mit 96 DPI importiert + +--- + ## Verwandte Seiten - [Dateien importieren](importing) - Wie jedes Format importiert wird diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/files/importing.md b/website/i18n/de/docusaurus-plugin-content-docs/current/files/importing.md index bdb296c35..bd951d7ca 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/files/importing.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/files/importing.md @@ -19,9 +19,12 @@ Rasterformate. Diese Seite erklärt, wie du Dateien importierst und für beste E | Format | Erweiterung | Importmethode | Am besten für | | -------- | --------------- | ------------------- | ---------------------------------------- | -| **PNG** | `.webp` | Tracing zu Vektoren | Fotos, Bilder mit Transparenz | +| **PNG** | `.png` | Tracing zu Vektoren | Fotos, Bilder mit Transparenz | | **JPEG** | `.jpg`, `.jpeg` | Tracing zu Vektoren | Fotos, Bilder mit kontinuierlichen Tönen | | **BMP** | `.bmp` | Tracing zu Vektoren | Einfache Grafiken, Screenshots | +| **GIF** | `.gif` | Tracing zu Vektoren | Einfache Grafiken, Animationen | +| **TIFF** | `.tif`, `.tiff` | Tracing zu Vektoren | Scans, Druckvorlagen | +| **WebP** | `.webp` | Tracing zu Vektoren | Webbilder | :::note[Raster-Import] @@ -357,7 +360,7 @@ farbcodierten Ebenen mit konfigurierbaren Laser-Einstellungen organisiert ist. --- -## Rasterbild-Import (PNG, JPG, BMP) +## Rasterbild-Import (PNG, JPG, BMP, GIF, TIFF, WebP) Rasterbilder werden **traced**, um Vektorpfade über den Import-Dialog zu erstellen. diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/files/formats.md b/website/i18n/es/docusaurus-plugin-content-docs/current/files/formats.md index e104979f8..67ce00251 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/files/formats.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/files/formats.md @@ -17,6 +17,9 @@ Rayforge, incluyendo capacidades, limitaciones y recomendaciones. | **PNG** | Raster | ✓ Traza | – | Fotos, imágenes | | **JPEG** | Raster | ✓ Traza | – | Fotos | | **BMP** | Raster | ✓ Traza | – | Gráficos simples | +| **GIF** | Raster | ✓ Traza | – | Gráficos simples, animaciones | +| **TIFF** | Raster | ✓ Traza | – | Escaneos, arte para impresión | +| **WebP** | Raster | ✓ Traza | – | Imágenes web | | **RFS** | Bosquejo | ✓ Directo | ✓ Exportar obj. | Bosquejos paramétricos | | **G-code** | Control | – | ✓ Principal | Salida de máquina | | **Proyecto Rayforge** | Proyecto | ✓ | ✓ | Guardar/cargar proyectos | @@ -285,6 +288,19 @@ No compatible --- +### GIF, TIFF y WebP + +**Extensión:** `.gif`, `.tif`, `.tiff`, `.webp` **Tipo MIME:** `image/gif`, `image/tiff`, +`image/webp` **Importación:** Trazo a vectores **Exportación:** No soportado + +Estos formatos se trazan igual que PNG. + +- Los **GIF animados** y los **TIFF de varias páginas** solo importan su primer fotograma o página +- Los archivos **TIFF** suelen guardar su resolución de impresión, que determina el tamaño físico +- Las imágenes sin resolución se importan a 96 DPI + +--- + ## Páginas relacionadas - [Importar archivos](importing) - Cómo importar cada formato diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/files/importing.md b/website/i18n/es/docusaurus-plugin-content-docs/current/files/importing.md index 763d4e217..3ffdfd698 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/files/importing.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/files/importing.md @@ -19,9 +19,12 @@ bits. Esta página explica cómo importar archivos y optimizarlos para obtener l | Formato | Extensión | Método de importación | Mejor para | | -------- | --------------- | --------------------- | -------------------------------------- | -| **PNG** | `.webp` | Trazo a vectores | Fotos, imágenes con transparencia | +| **PNG** | `.png` | Trazo a vectores | Fotos, imágenes con transparencia | | **JPEG** | `.jpg`, `.jpeg` | Trazo a vectores | Fotos, imágenes de tono continuo | | **BMP** | `.bmp` | Trazo a vectores | Gráficos simples, capturas de pantalla | +| **GIF** | `.gif` | Trazo a vectores | Gráficos simples, animaciones | +| **TIFF** | `.tif`, `.tiff` | Trazo a vectores | Escaneos, arte para impresión | +| **WebP** | `.webp` | Trazo a vectores | Imágenes web | :::note[Importación de mapa de bits] @@ -355,7 +358,7 @@ configuraciones láser ajustables. --- -## Importación de imágenes de mapa de bits (PNG, JPG, BMP) +## Importación de imágenes de mapa de bits (PNG, JPG, BMP, GIF, TIFF, WebP) Las imágenes de mapa de bits se **trazan** para crear trazados vectoriales utilizando el diálogo de importación. diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/files/formats.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/files/formats.md index 395a1a4e9..b030a9109 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/files/formats.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/files/formats.md @@ -7,19 +7,22 @@ Rayforge, y compris les capacités, les limitations et les recommandations. ### Référence rapide -| Format | Type | Importation | Exportation | Utilisation recommandée | -| ------------------- | --------- | ---------------- | ------------- | -------------------------------- | -| **SVG** | Vecteur | ✓ Direct / Trace | ✓ Export obj. | Format de conception principal | -| **DXF** | Vecteur | ✓ Direct | ✓ Export obj. | Échange CAO | -| **PDF** | Mixte | ✓ Direct / Trace | – | Documents avec contenu vectoriel | -| **LightBurn** | Vecteur | ✓ Direct | – | Projets LightBurn | -| **Ruida** | Vecteur | ✓ Direct | – | Fichiers de tâches Ruida | -| **PNG** | Matriciel | ✓ Trace | – | Photos, images | -| **JPEG** | Matriciel | ✓ Trace | – | Photos | -| **BMP** | Matriciel | ✓ Trace | – | Graphiques simples | -| **RFS** | Croquis | ✓ Direct | ✓ Export obj. | Croquis paramétriques | -| **G-code** | Commande | – | ✓ Principal | Sortie machine | -| **Projet Rayforge** | Projet | ✓ | ✓ | Enregistrer/charger projets | +| Format | Type | Importation | Exportation | Utilisation recommandée | +| ------------------- | --------- | ---------------- | ------------- | ------------------------------------ | +| **SVG** | Vecteur | ✓ Direct / Trace | ✓ Export obj. | Format de conception principal | +| **DXF** | Vecteur | ✓ Direct | ✓ Export obj. | Échange CAO | +| **PDF** | Mixte | ✓ Direct / Trace | – | Documents avec contenu vectoriel | +| **LightBurn** | Vecteur | ✓ Direct | – | Projets LightBurn | +| **Ruida** | Vecteur | ✓ Direct | – | Fichiers de tâches Ruida | +| **PNG** | Matriciel | ✓ Trace | – | Photos, images | +| **JPEG** | Matriciel | ✓ Trace | – | Photos | +| **BMP** | Matriciel | ✓ Trace | – | Graphiques simples | +| **GIF** | Matriciel | ✓ Trace | – | Graphiques simples, animations | +| **TIFF** | Matriciel | ✓ Trace | – | Numérisations, fichiers d'impression | +| **WebP** | Matriciel | ✓ Trace | – | Images web | +| **RFS** | Croquis | ✓ Direct | ✓ Export obj. | Croquis paramétriques | +| **G-code** | Commande | – | ✓ Principal | Sortie machine | +| **Projet Rayforge** | Projet | ✓ | ✓ | Enregistrer/charger projets | --- @@ -287,6 +290,20 @@ vectoriels. --- +### GIF, TIFF et WebP + +**Extension :** `.gif`, `.tif`, `.tiff`, `.webp` **Type MIME :** `image/gif`, `image/tiff`, +`image/webp` **Importation :** Vectorisation **Exportation :** Non pris en charge + +Ces formats sont vectorisés de la même manière que le PNG. + +- Les **GIF animés** et les **TIFF multipages** n'importent que leur première image ou page +- Les fichiers **TIFF** enregistrent généralement leur résolution d'impression, qui détermine la + taille physique +- Les images sans résolution sont importées à 96 DPI + +--- + ## Pages associées - [Importer des fichiers](importing) - Comment importer chaque format diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/files/importing.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/files/importing.md index 7dcf70745..9c1a8eb02 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/files/importing.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/files/importing.md @@ -20,9 +20,12 @@ meilleurs résultats. | Format | Extension | Méthode d'importation | Idéal pour | | -------- | --------------- | --------------------- | ------------------------------------ | -| **PNG** | `.webp` | Vectorisation | Photos, images avec transparence | +| **PNG** | `.png` | Vectorisation | Photos, images avec transparence | | **JPEG** | `.jpg`, `.jpeg` | Vectorisation | Photos, images à tons continus | | **BMP** | `.bmp` | Vectorisation | Graphiques simples, captures d'écran | +| **GIF** | `.gif` | Vectorisation | Graphiques simples, animations | +| **TIFF** | `.tif`, `.tiff` | Vectorisation | Numérisations, fichiers d'impression | +| **WebP** | `.webp` | Vectorisation | Images web | :::note[Importation d'images matricielles] @@ -362,7 +365,7 @@ colorés avec des paramètres laser configurables. --- -## Importation d'images matricielles (PNG, JPG, BMP) +## Importation d'images matricielles (PNG, JPG, BMP, GIF, TIFF, WebP) Les images matricielles sont **vectorisées** pour créer des tracés vectoriels via la boîte de dialogue d'importation. diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/files/formats.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/files/formats.md index 586d99127..06b3a63d1 100644 --- a/website/i18n/hi/docusaurus-plugin-content-docs/current/files/formats.md +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/files/formats.md @@ -23,6 +23,9 @@ description: | **PNG** | रास्टर | ✓ Trace | – | फ़ोटो, छवियाँ | | **JPEG** | रास्टर | ✓ Trace | – | फ़ोटो | | **BMP** | रास्टर | ✓ Trace | – | सरल ग्राफ़िक्स | +| **GIF** | रास्टर | ✓ Trace | – | सरल ग्राफ़िक्स, एनिमेशन | +| **TIFF** | रास्टर | ✓ Trace | – | स्कैन, प्रिंट आर्टवर्क | +| **WebP** | रास्टर | ✓ Trace | – | वेब छवियाँ | | **RFS** | स्केच | ✓ Direct | ✓ Object export | पैरामीट्रिक स्केच | | **G-code** | नियंत्रण | – | ✓ प्राथमिक | मशीन आउटपुट | | **Rayforge परियोजना** | परियोजना | ✓ | ✓ | परियोजनाएँ सहेजें/लोड करें | @@ -272,6 +275,19 @@ PDF फ़ाइलों में वास्तविक वेक्टर --- +### GIF, TIFF और WebP + +**एक्सटेंशन:** `.gif`, `.tif`, `.tiff`, `.webp` **MIME प्रकार:** `image/gif`, `image/tiff`, +`image/webp` **आयात:** वेक्टर में ट्रेस **निर्यात:** समर्थित नहीं + +इन फ़ॉर्मेट को PNG की तरह ही ट्रेस किया जाता है. + +- **एनिमेटेड GIF** और **कई पृष्ठों वाली TIFF** फ़ाइलों का केवल पहला फ़्रेम या पृष्ठ आयात होता है +- **TIFF** फ़ाइलें आमतौर पर अपना प्रिंट रिज़ॉल्यूशन सहेजती हैं, जिससे भौतिक आकार तय होता है +- बिना रिज़ॉल्यूशन वाली छवियाँ 96 DPI पर आयात होती हैं + +--- + ## संबंधित पृष्ठ - [फ़ाइलें आयात करना](importing) - प्रत्येक फ़ॉर्मेट कैसे आयात करें diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/files/importing.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/files/importing.md index 7523efd0d..68e25cc63 100644 --- a/website/i18n/hi/docusaurus-plugin-content-docs/current/files/importing.md +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/files/importing.md @@ -25,9 +25,12 @@ Rayforge विभिन्न फ़ाइल फ़ॉर्मेट आय | फ़ॉर्मेट | एक्सटेंशन | आयात विधि | सर्वोत्तम के लिए | | -------- | --------------- | ---------------- | ----------------------------- | -| **PNG** | `.webp` | वेक्टर में ट्रेस | फ़ोटो, पारदर्शिता वाली छवियाँ | +| **PNG** | `.png` | वेक्टर में ट्रेस | फ़ोटो, पारदर्शिता वाली छवियाँ | | **JPEG** | `.jpg`, `.jpeg` | वेक्टर में ट्रेस | फ़ोटो, सतत-टोन छवियाँ | | **BMP** | `.bmp` | वेक्टर में ट्रेस | सरल ग्राफ़िक्स, स्क्रीनशॉट | +| **GIF** | `.gif` | वेक्टर में ट्रेस | सरल ग्राफ़िक्स, एनिमेशन | +| **TIFF** | `.tif`, `.tiff` | वेक्टर में ट्रेस | स्कैन, प्रिंट आर्टवर्क | +| **WebP** | `.webp` | वेक्टर में ट्रेस | वेब छवियाँ | :::note[रास्टर आयात] @@ -351,7 +354,7 @@ LightBurn (.lbrn / .lbrn2) फ़ाइलें LightBurn लेज़र क --- -## रास्टर छवि आयात (PNG, JPG, BMP) +## रास्टर छवि आयात (PNG, JPG, BMP, GIF, TIFF, WebP) रास्टर छवियों को आयात संवाद का उपयोग करके वेक्टर पाथ बनाने के लिए **ट्रेस** किया जाता है. diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/files/formats.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/files/formats.md index d411670f2..d4f889629 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/files/formats.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/files/formats.md @@ -7,19 +7,22 @@ Rayforge, incluindo capacidades, limitações e recomendações. ### Referência rápida -| Formato | Tipo | Importação | Exportação | Uso recomendado | -| -------------------- | -------- | ----------------- | --------------- | ---------------------------- | -| **SVG** | Vetor | ✓ Direto / Traçar | ✓ Exportar obj. | Formato de design principal | -| **DXF** | Vetor | ✓ Direto | ✓ Exportar obj. | Intercâmbio CAD | -| **PDF** | Misto | ✓ Direto / Traçar | – | Documentos com conteúdo vet. | -| **LightBurn** | Vetor | ✓ Direto | – | Projetos LightBurn | -| **Ruida** | Vetor | ✓ Direto | – | Arquivos de trabalho Ruida | -| **PNG** | Raster | ✓ Traçar | – | Fotos, imagens | -| **JPEG** | Raster | ✓ Traçar | – | Fotos | -| **BMP** | Raster | ✓ Traçar | – | Gráficos simples | -| **RFS** | Esboço | ✓ Direto | ✓ Exportar obj. | Esboços paramétricos | -| **G-code** | Controle | – | ✓ Principal | Saída da máquina | -| **Projeto Rayforge** | Projeto | ✓ | ✓ | Salvar/carregar projetos | +| Formato | Tipo | Importação | Exportação | Uso recomendado | +| -------------------- | -------- | ----------------- | --------------- | ----------------------------------- | +| **SVG** | Vetor | ✓ Direto / Traçar | ✓ Exportar obj. | Formato de design principal | +| **DXF** | Vetor | ✓ Direto | ✓ Exportar obj. | Intercâmbio CAD | +| **PDF** | Misto | ✓ Direto / Traçar | – | Documentos com conteúdo vet. | +| **LightBurn** | Vetor | ✓ Direto | – | Projetos LightBurn | +| **Ruida** | Vetor | ✓ Direto | – | Arquivos de trabalho Ruida | +| **PNG** | Raster | ✓ Traçar | – | Fotos, imagens | +| **JPEG** | Raster | ✓ Traçar | – | Fotos | +| **BMP** | Raster | ✓ Traçar | – | Gráficos simples | +| **GIF** | Raster | ✓ Traçar | – | Gráficos simples, animações | +| **TIFF** | Raster | ✓ Traçar | – | Digitalizações, arte para impressão | +| **WebP** | Raster | ✓ Traçar | – | Imagens da web | +| **RFS** | Esboço | ✓ Direto | ✓ Exportar obj. | Esboços paramétricos | +| **G-code** | Controle | – | ✓ Principal | Saída da máquina | +| **Projeto Rayforge** | Projeto | ✓ | ✓ | Salvar/carregar projetos | --- @@ -285,6 +288,19 @@ suportado --- +### GIF, TIFF e WebP + +**Extensão:** `.gif`, `.tif`, `.tiff`, `.webp` **Tipo MIME:** `image/gif`, `image/tiff`, +`image/webp` **Importação:** Vetorização **Exportação:** Não suportado + +Esses formatos são vetorizados da mesma forma que o PNG. + +- **GIFs animados** e **TIFFs de várias páginas** importam apenas o primeiro quadro ou página +- Arquivos **TIFF** geralmente guardam sua resolução de impressão, que define o tamanho físico +- Imagens sem resolução são importadas a 96 DPI + +--- + ## Páginas relacionadas - [Importando arquivos](importing) - Como importar cada formato diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/files/importing.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/files/importing.md index 1b22d4b03..993f66e8a 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/files/importing.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/files/importing.md @@ -17,11 +17,14 @@ explica como importar arquivos e otimizá-los para obter os melhores resultados. ### Formatos raster -| Formato | Extensão | Método de importação | Ideal para | -| -------- | --------------- | -------------------- | ---------------------------------- | -| **PNG** | `.webp` | Vetorização | Fotos, imagens com transparência | -| **JPEG** | `.jpg`, `.jpeg` | Vetorização | Fotos, imagens de tom contínuo | -| **BMP** | `.bmp` | Vetorização | Gráficos simples, capturas de tela | +| Formato | Extensão | Método de importação | Ideal para | +| -------- | --------------- | -------------------- | ----------------------------------- | +| **PNG** | `.png` | Vetorização | Fotos, imagens com transparência | +| **JPEG** | `.jpg`, `.jpeg` | Vetorização | Fotos, imagens de tom contínuo | +| **BMP** | `.bmp` | Vetorização | Gráficos simples, capturas de tela | +| **GIF** | `.gif` | Vetorização | Gráficos simples, animações | +| **TIFF** | `.tif`, `.tiff` | Vetorização | Digitalizações, arte para impressão | +| **WebP** | `.webp` | Vetorização | Imagens da web | :::note[Importação de imagens raster] @@ -354,7 +357,7 @@ configurações de laser ajustáveis. --- -## Importação de imagens raster (PNG, JPG, BMP) +## Importação de imagens raster (PNG, JPG, BMP, GIF, TIFF, WebP) As imagens raster são **vetorizadas** para criar caminhos vetoriais usando o diálogo de importação. diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/files/formats.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/files/formats.md index e11526fc0..4f15b9a78 100644 --- a/website/i18n/uk/docusaurus-plugin-content-docs/current/files/formats.md +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/files/formats.md @@ -17,6 +17,9 @@ | **PNG** | Растр | ✓ Трасування | – | Фотографії, зображення | | **JPEG** | Растр | ✓ Трасування | – | Фотографії | | **BMP** | Растр | ✓ Трасування | – | Проста графіка | +| **GIF** | Растр | ✓ Трасування | – | Проста графіка, анімації | +| **TIFF** | Растр | ✓ Трасування | – | Скани, макети для друку | +| **WebP** | Растр | ✓ Трасування | – | Вебзображення | | **RFS** | Ескіз | ✓ Прямий | ✓ Експорт об'єкт. | Параметричні ескізи | | **G-code** | Керування | – | ✓ Основний | Вивід для машини | | **Проєкт Rayforge** | Проєкт | ✓ | ✓ | Збер./завант. проєктів | @@ -284,6 +287,19 @@ LightBurn на Rayforge. --- +### GIF, TIFF і WebP + +**Розширення:** `.gif`, `.tif`, `.tiff`, `.webp` **MIME-тип:** `image/gif`, `image/tiff`, +`image/webp` **Імпорт:** Трасування у вектори **Експорт:** Не підтримується + +Ці формати трасуються так само, як PNG. + +- **Анімовані GIF** і **багатосторінкові TIFF** імпортують лише перший кадр або сторінку +- Файли **TIFF** зазвичай зберігають роздільну здатність друку, яка визначає фізичний розмір +- Зображення без роздільної здатності імпортуються з 96 DPI + +--- + ## Пов'язані сторінки - [Імпорт файлів](importing) - Як імпортувати кожен формат diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/files/importing.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/files/importing.md index 6918b8c1c..68b8eb6ef 100644 --- a/website/i18n/uk/docusaurus-plugin-content-docs/current/files/importing.md +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/files/importing.md @@ -19,9 +19,12 @@ Rayforge підтримує імпортування різних файлови | Формат | Розширення | Метод імпорту | Найкраще для | | -------- | --------------- | -------------------- | --------------------------------------------- | -| **PNG** | `.webp` | Трасування у вектори | Фотографії, зображення з прозорістю | +| **PNG** | `.png` | Трасування у вектори | Фотографії, зображення з прозорістю | | **JPEG** | `.jpg`, `.jpeg` | Трасування у вектори | Фотографії, зображення з безперервними тонами | | **BMP** | `.bmp` | Трасування у вектори | Прості графіки, знімки екрана | +| **GIF** | `.gif` | Трасування у вектори | Проста графіка, анімації | +| **TIFF** | `.tif`, `.tiff` | Трасування у вектори | Скани, макети для друку | +| **WebP** | `.webp` | Трасування у вектори | Вебзображення | :::note Імпортування растрових зображень ::: @@ -344,7 +347,7 @@ PDF, де текст не перетворено на контури — Rayforg --- -## Імпортування растрових зображень (PNG, JPG, BMP) +## Імпортування растрових зображень (PNG, JPG, BMP, GIF, TIFF, WebP) Растрові зображення **трасуються** для створення векторних контурів за допомогою діалогового вікна імпорту. diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/files/formats.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/files/formats.md index 77959879a..6c2f0d703 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/files/formats.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/files/formats.md @@ -16,6 +16,9 @@ | **PNG** | 栅格 | ✓ 描摹 | – | 照片、图像 | | **JPEG** | 栅格 | ✓ 描摹 | – | 照片 | | **BMP** | 栅格 | ✓ 描摹 | – | 简单图形 | +| **GIF** | 栅格 | ✓ 描摹 | – | 简单图形、动画 | +| **TIFF** | 栅格 | ✓ 描摹 | – | 扫描件、印刷稿 | +| **WebP** | 栅格 | ✓ 描摹 | – | 网页图片 | | **RFS** | 草图 | ✓ 直接 | ✓ 对象导出 | 参数化草图 | | **G-code** | 控制 | – | ✓ 主要 | 机器输出 | | **Rayforge 项目** | 项目 | ✓ | ✓ | 保存/加载项目 | @@ -271,6 +274,19 @@ PDF 文件可以包含实际的矢量路径,Rayforge 在可用时直接导入 --- +### GIF、TIFF 和 WebP + +**扩展名:** `.gif`、`.tif`、`.tiff`、`.webp` **MIME 类型:** +`image/gif`、`image/tiff`、`image/webp` **导入:** 描摹为矢量 **导出:** 不支持 + +这些格式的描摹方式与 PNG 相同。 + +- **动画 GIF** 和**多页 TIFF** 只导入第一帧或第一页 +- **TIFF** 文件通常保存打印分辨率,用于确定物理尺寸 +- 没有分辨率信息的图像按 96 DPI 导入 + +--- + ## 相关页面 - [导入文件](importing) - 如何导入每种格式 diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/files/importing.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/files/importing.md index c4e3142d1..fbcee9ac1 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/files/importing.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/files/importing.md @@ -18,9 +18,12 @@ Rayforge 支持导入多种文件格式,包括矢量格式和光栅格式。 | 格式 | 扩展名 | 导入方式 | 适用场景 | | -------- | --------------- | ---------- | -------------------- | -| **PNG** | `.webp` | 描摹为矢量 | 照片、带透明度的图像 | +| **PNG** | `.png` | 描摹为矢量 | 照片、带透明度的图像 | | **JPEG** | `.jpg`, `.jpeg` | 描摹为矢量 | 照片、连续色调图像 | | **BMP** | `.bmp` | 描摹为矢量 | 简单图形、截图 | +| **GIF** | `.gif` | 描摹为矢量 | 简单图形、动画 | +| **TIFF** | `.tif`, `.tiff` | 描摹为矢量 | 扫描件、印刷稿 | +| **WebP** | `.webp` | 描摹为矢量 | 网页图片 | :::note[光栅图像导入] @@ -321,7 +324,7 @@ LightBurn(.lbrn / --- -## 光栅图像导入(PNG、JPG、BMP) +## 光栅图像导入(PNG、JPG、BMP、GIF、TIFF、WebP) 光栅图像通过导入对话框被**描摹**为矢量路径。 From d0a009be990b159f20e36635960a8325ba2f24f2 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Fri, 9 Oct 2026 13:03:00 +0200 Subject: [PATCH 008/104] feat(import): import Adobe Illustrator and EPS/PostScript files Illustrator has saved .ai files with an embedded PDF by default since version 9. AiImporter detects the PDF header and hands those files to the PDF importer unchanged, so they need no extra dependency. EPS, PostScript and PostScript-only .ai files are converted to PDF with Ghostscript (-dSAFER, -dEPSCrop, -dNoOutputFonts), an optional runtime dependency, and then imported by the PDF importer. The EPS bounding box becomes the page size and text becomes outlines. Conversions are cached by content, so the live import preview does not run Ghostscript again for every change. The source asset keeps the converted PDF, so a saved project renders and re-imports on machines without Ghostscript. Without Ghostscript the import dialog shows a clear message; for a legacy .ai file it also explains how to save it again with PDF compatibility. Refs #517 --- CHANGELOG.md | 6 + rayforge/image/__init__.py | 3 + rayforge/image/postscript/__init__.py | 0 rayforge/image/postscript/ghostscript.py | 110 ++++++++ rayforge/image/postscript/importer.py | 135 ++++++++++ rayforge/locale/de/LC_MESSAGES/rayforge.po | 13 + rayforge/locale/en/LC_MESSAGES/rayforge.po | 13 + rayforge/locale/es/LC_MESSAGES/rayforge.po | 13 + rayforge/locale/fr/LC_MESSAGES/rayforge.po | 13 + rayforge/locale/hi/LC_MESSAGES/rayforge.po | 13 + rayforge/locale/pt/LC_MESSAGES/rayforge.po | 13 + rayforge/locale/rayforge.pot | 13 + rayforge/locale/uk/LC_MESSAGES/rayforge.po | 13 + rayforge/locale/zh_CN/LC_MESSAGES/rayforge.po | 13 + .../postscript/test_postscript_import.py | 245 ++++++++++++++++++ website/docs/files/formats.md | 32 +++ website/docs/files/importing.md | 27 +- .../current/files/formats.md | 34 +++ .../current/files/importing.md | 27 +- .../current/files/formats.md | 66 +++-- .../current/files/importing.md | 28 +- .../current/files/formats.md | 34 +++ .../current/files/importing.md | 28 +- .../current/files/formats.md | 32 +++ .../current/files/importing.md | 27 +- .../current/files/formats.md | 33 +++ .../current/files/importing.md | 28 +- .../current/files/formats.md | 33 +++ .../current/files/importing.md | 27 +- .../current/files/formats.md | 57 ++-- .../current/files/importing.md | 25 +- 31 files changed, 1066 insertions(+), 88 deletions(-) create mode 100644 rayforge/image/postscript/__init__.py create mode 100644 rayforge/image/postscript/ghostscript.py create mode 100644 rayforge/image/postscript/importer.py create mode 100644 tests/image/postscript/test_postscript_import.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a2c135985..bc1b1cd5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Import: Adobe Illustrator (`.ai`) and EPS/PostScript files. AI files + saved with PDF compatibility (the default since Illustrator 9) import + like PDFs; EPS, PostScript and older AI files are converted to PDF + with Ghostscript, an optional dependency, with a clear message when + it is missing. Text becomes outlines and the EPS bounding box sets + the page size (#517) - Import: GIF, TIFF and WebP bitmaps, traced like PNG. Animated GIFs and multi-page TIFFs import their first frame or page (#517) - Sketcher: boolean operations. Union, Difference, Intersection and diff --git a/rayforge/image/__init__.py b/rayforge/image/__init__.py index 7f6166b67..a32f2f83b 100644 --- a/rayforge/image/__init__.py +++ b/rayforge/image/__init__.py @@ -30,6 +30,7 @@ from .pdf.renderer import PDF_RENDERER from .png.importer import PngImporter from .png.renderer import PNG_RENDERER +from .postscript.importer import AiImporter, EpsImporter from .procedural.renderer import PROCEDURAL_RENDERER from .registry import ( exporter_registry, @@ -250,8 +251,10 @@ def get_renderer_for_asset(asset_type: str) -> Renderer | None: __all__ = [ + "AiImporter", "BmpImporter", "DxfImporter", + "EpsImporter", "GeometryDxfExporter", "GeometrySvgExporter", "GifImporter", diff --git a/rayforge/image/postscript/__init__.py b/rayforge/image/postscript/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/rayforge/image/postscript/ghostscript.py b/rayforge/image/postscript/ghostscript.py new file mode 100644 index 000000000..406171700 --- /dev/null +++ b/rayforge/image/postscript/ghostscript.py @@ -0,0 +1,110 @@ +""" +Conversion of PostScript, EPS and legacy Illustrator files to PDF with +Ghostscript, an optional runtime dependency. +""" + +import hashlib +import logging +import shutil +import subprocess +import tempfile +import threading +from collections import OrderedDict +from pathlib import Path + +logger = logging.getLogger(__name__) + +GHOSTSCRIPT_NAMES = ("gs", "gswin64c", "gswin32c") +CONVERSION_TIMEOUT_S = 120 +_CACHE_SIZE = 8 +_cache: "OrderedDict[str, bytes]" = OrderedDict() +_cache_lock = threading.Lock() + + +class GhostscriptNotFound(Exception): + """Raised when no Ghostscript executable is available.""" + + +class GhostscriptError(Exception): + """Raised when Ghostscript fails to convert a file.""" + + +def find_ghostscript() -> str | None: + """Returns the path of the Ghostscript console executable, if any.""" + for name in GHOSTSCRIPT_NAMES: + path = shutil.which(name) + if path: + return path + return None + + +def clear_cache(): + """Forgets all cached conversions.""" + with _cache_lock: + _cache.clear() + + +def _command(gs: str, source: Path, target: Path) -> list[str]: + return [ + gs, + "-q", + "-dSAFER", + "-dBATCH", + "-dNOPAUSE", + "-sDEVICE=pdfwrite", + "-dEPSCrop", + "-dNoOutputFonts", + f"-sOutputFile={target}", + str(source), + ] + + +def _run(gs: str, data: bytes) -> bytes: + with tempfile.TemporaryDirectory(prefix="rayforge-gs-") as tmp: + source = Path(tmp) / "input.ps" + target = Path(tmp) / "output.pdf" + source.write_bytes(data) + try: + proc = subprocess.run( + _command(gs, source, target), + capture_output=True, + timeout=CONVERSION_TIMEOUT_S, + check=False, + ) + except (OSError, subprocess.TimeoutExpired) as e: + raise GhostscriptError(str(e)) from e + pdf = target.read_bytes() if target.exists() else b"" + if proc.returncode != 0 or not pdf.startswith(b"%PDF"): + message = proc.stderr.decode(errors="replace").strip() + raise GhostscriptError(message or f"exit status {proc.returncode}") + return pdf + + +def convert_to_pdf(data: bytes) -> bytes: + """ + Converts PostScript or EPS data to PDF. Text is converted to outlines + and an EPS bounding box becomes the page size. Results are cached by + content, so repeated previews of the same file convert only once. + + Raises: + GhostscriptNotFound: If Ghostscript is not installed. + GhostscriptError: If the conversion fails. + """ + key = hashlib.sha256(data).hexdigest() + with _cache_lock: + cached = _cache.get(key) + if cached is not None: + _cache.move_to_end(key) + return cached + + gs = find_ghostscript() + if gs is None: + raise GhostscriptNotFound() + + logger.debug(f"Converting {len(data)} bytes of PostScript with {gs}") + pdf = _run(gs, data) + with _cache_lock: + _cache[key] = pdf + while len(_cache) > _CACHE_SIZE: + _cache.popitem(last=False) + return pdf diff --git a/rayforge/image/postscript/importer.py b/rayforge/image/postscript/importer.py new file mode 100644 index 000000000..bf7831cb5 --- /dev/null +++ b/rayforge/image/postscript/importer.py @@ -0,0 +1,135 @@ +import logging +from gettext import gettext as _ +from pathlib import Path + +from ...core.source_asset import SourceAsset +from ...core.vectorization_spec import VectorizationSpec +from ..pdf.importer import PdfImporter +from ..structures import ImportManifest, ImportResult +from . import ghostscript + +logger = logging.getLogger(__name__) + +PDF_MAGIC = b"%PDF-" +PDF_HEADER_WINDOW = 1024 + + +def is_pdf(data: bytes) -> bool: + """True if the data is a PDF stream (PDF allows leading junk).""" + return PDF_MAGIC in data[:PDF_HEADER_WINDOW] + + +class _PostScriptImporter(PdfImporter): + """ + Imports files that are either PDF streams or PostScript. PDF data is + handed to the PDF importer unchanged; PostScript is first converted + to PDF with Ghostscript. The source asset keeps the PDF, so projects + render and re-import without Ghostscript. + """ + + def __init__(self, data: bytes, source_file: Path | None = None): + super().__init__(data, source_file) + self._pdf_ready: bool | None = None + + def _missing_ghostscript_message(self) -> str: + return _( + "Importing EPS and PostScript files requires Ghostscript, " + "which was not found. Install Ghostscript and try again." + ) + + def _ensure_pdf(self) -> bool: + if self._pdf_ready is not None: + return self._pdf_ready + if is_pdf(self.raw_data): + self._pdf_ready = True + return True + try: + self.raw_data = ghostscript.convert_to_pdf(self.raw_data) + self._pdf_ready = True + except ghostscript.GhostscriptNotFound: + self.add_error(self._missing_ghostscript_message()) + self._pdf_ready = False + except ghostscript.GhostscriptError as e: + logger.warning( + f"Ghostscript failed for {self.source_file.name}: {e}" + ) + self.add_error( + _("Ghostscript could not convert {filename}: {error}").format( + filename=self.source_file.name, error=e + ) + ) + self._pdf_ready = False + return self._pdf_ready + + def _failed_result(self) -> ImportResult: + return ImportResult( + payload=None, + parse_result=None, + warnings=self._warnings, + errors=self._errors, + ) + + def scan(self) -> ImportManifest: + if not self._ensure_pdf(): + return ImportManifest( + title=self.source_file.name, + warnings=self._warnings, + errors=self._errors, + ) + manifest = super().scan() + manifest.title = self.source_file.name + return manifest + + def get_doc_items( + self, vectorization_spec: VectorizationSpec | None = None + ) -> ImportResult | None: + if not self._ensure_pdf(): + return self._failed_result() + return super().get_doc_items(vectorization_spec) + + def get_doc_items_for_reimport( + self, + existing_source_asset: SourceAsset, + vectorization_spec: VectorizationSpec, + ) -> ImportResult | None: + if not self._ensure_pdf(): + return self._failed_result() + return super().get_doc_items_for_reimport( + existing_source_asset, vectorization_spec + ) + + +class AiImporter(_PostScriptImporter): + """ + Adobe Illustrator files. Since Illustrator 9 they embed a PDF by + default ("Create PDF Compatible File"); older or PostScript-only + files need Ghostscript. + """ + + label = "Adobe Illustrator files" + mime_types = ( + "application/illustrator", + "application/vnd.adobe.illustrator", + ) + extensions = (".ai",) + + def _missing_ghostscript_message(self) -> str: + return _( + "This Illustrator file was saved without PDF compatibility. " + "Rayforge can only read it with Ghostscript, which was not " + "found. Install Ghostscript, or save the file again in " + 'Illustrator with "Create PDF Compatible File" enabled.' + ) + + +class EpsImporter(_PostScriptImporter): + """Encapsulated PostScript and PostScript files, via Ghostscript.""" + + label = "EPS and PostScript files" + mime_types = ( + "image/x-eps", + "application/postscript", + "application/eps", + "image/eps", + ) + extensions = (".eps", ".epsf", ".ps") diff --git a/rayforge/locale/de/LC_MESSAGES/rayforge.po b/rayforge/locale/de/LC_MESSAGES/rayforge.po index fb1cdc1e2..7228ea10f 100644 --- a/rayforge/locale/de/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/de/LC_MESSAGES/rayforge.po @@ -10475,3 +10475,16 @@ msgstr "Bayer 8" #, python-brace-format msgid "Could not read the {format} image: {error}" msgstr "Das {format}-Bild konnte nicht gelesen werden: {error}" + +#: rayforge/image/postscript/importer.py +msgid "Importing EPS and PostScript files requires Ghostscript, which was not found. Install Ghostscript and try again." +msgstr "Für den Import von EPS- und PostScript-Dateien wird Ghostscript benötigt, das nicht gefunden wurde. Installiere Ghostscript und versuche es erneut." + +#: rayforge/image/postscript/importer.py +#, python-brace-format +msgid "Ghostscript could not convert {filename}: {error}" +msgstr "Ghostscript konnte {filename} nicht umwandeln: {error}" + +#: rayforge/image/postscript/importer.py +msgid "This Illustrator file was saved without PDF compatibility. Rayforge can only read it with Ghostscript, which was not found. Install Ghostscript, or save the file again in Illustrator with \"Create PDF Compatible File\" enabled." +msgstr "Diese Illustrator-Datei wurde ohne PDF-Kompatibilität gespeichert. Rayforge kann sie nur mit Ghostscript lesen, das nicht gefunden wurde. Installiere Ghostscript oder speichere die Datei in Illustrator erneut mit aktivierter Option „PDF-kompatible Datei erstellen“." diff --git a/rayforge/locale/en/LC_MESSAGES/rayforge.po b/rayforge/locale/en/LC_MESSAGES/rayforge.po index 54f1b033c..912f9db30 100644 --- a/rayforge/locale/en/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/en/LC_MESSAGES/rayforge.po @@ -9833,3 +9833,16 @@ msgstr "" #, python-brace-format msgid "Could not read the {format} image: {error}" msgstr "" + +#: rayforge/image/postscript/importer.py +msgid "Importing EPS and PostScript files requires Ghostscript, which was not found. Install Ghostscript and try again." +msgstr "" + +#: rayforge/image/postscript/importer.py +#, python-brace-format +msgid "Ghostscript could not convert {filename}: {error}" +msgstr "" + +#: rayforge/image/postscript/importer.py +msgid "This Illustrator file was saved without PDF compatibility. Rayforge can only read it with Ghostscript, which was not found. Install Ghostscript, or save the file again in Illustrator with \"Create PDF Compatible File\" enabled." +msgstr "" diff --git a/rayforge/locale/es/LC_MESSAGES/rayforge.po b/rayforge/locale/es/LC_MESSAGES/rayforge.po index bf225fc19..47af754f4 100644 --- a/rayforge/locale/es/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/es/LC_MESSAGES/rayforge.po @@ -10449,3 +10449,16 @@ msgstr "Bayer 8" #, python-brace-format msgid "Could not read the {format} image: {error}" msgstr "No se pudo leer la imagen {format}: {error}" + +#: rayforge/image/postscript/importer.py +msgid "Importing EPS and PostScript files requires Ghostscript, which was not found. Install Ghostscript and try again." +msgstr "Para importar archivos EPS y PostScript se necesita Ghostscript, que no se encontró. Instala Ghostscript e inténtalo de nuevo." + +#: rayforge/image/postscript/importer.py +#, python-brace-format +msgid "Ghostscript could not convert {filename}: {error}" +msgstr "Ghostscript no pudo convertir {filename}: {error}" + +#: rayforge/image/postscript/importer.py +msgid "This Illustrator file was saved without PDF compatibility. Rayforge can only read it with Ghostscript, which was not found. Install Ghostscript, or save the file again in Illustrator with \"Create PDF Compatible File\" enabled." +msgstr "Este archivo de Illustrator se guardó sin compatibilidad con PDF. Rayforge solo puede leerlo con Ghostscript, que no se encontró. Instala Ghostscript o vuelve a guardar el archivo en Illustrator con la opción «Crear archivo compatible con PDF» activada." diff --git a/rayforge/locale/fr/LC_MESSAGES/rayforge.po b/rayforge/locale/fr/LC_MESSAGES/rayforge.po index 7370b7b3c..b80e61ddc 100644 --- a/rayforge/locale/fr/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/fr/LC_MESSAGES/rayforge.po @@ -10496,3 +10496,16 @@ msgstr "Bayer 8" #, python-brace-format msgid "Could not read the {format} image: {error}" msgstr "Impossible de lire l'image {format} : {error}" + +#: rayforge/image/postscript/importer.py +msgid "Importing EPS and PostScript files requires Ghostscript, which was not found. Install Ghostscript and try again." +msgstr "L'importation de fichiers EPS et PostScript nécessite Ghostscript, qui est introuvable. Installez Ghostscript et réessayez." + +#: rayforge/image/postscript/importer.py +#, python-brace-format +msgid "Ghostscript could not convert {filename}: {error}" +msgstr "Ghostscript n'a pas pu convertir {filename} : {error}" + +#: rayforge/image/postscript/importer.py +msgid "This Illustrator file was saved without PDF compatibility. Rayforge can only read it with Ghostscript, which was not found. Install Ghostscript, or save the file again in Illustrator with \"Create PDF Compatible File\" enabled." +msgstr "Ce fichier Illustrator a été enregistré sans compatibilité PDF. Rayforge ne peut le lire qu'avec Ghostscript, qui est introuvable. Installez Ghostscript ou enregistrez à nouveau le fichier dans Illustrator avec l'option « Créer un fichier compatible PDF » activée." diff --git a/rayforge/locale/hi/LC_MESSAGES/rayforge.po b/rayforge/locale/hi/LC_MESSAGES/rayforge.po index f7bfe7642..b86be0307 100644 --- a/rayforge/locale/hi/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/hi/LC_MESSAGES/rayforge.po @@ -10155,3 +10155,16 @@ msgstr "Bayer 8" #, python-brace-format msgid "Could not read the {format} image: {error}" msgstr "{format} छवि पढ़ी नहीं जा सकी: {error}" + +#: rayforge/image/postscript/importer.py +msgid "Importing EPS and PostScript files requires Ghostscript, which was not found. Install Ghostscript and try again." +msgstr "EPS और PostScript फ़ाइलें आयात करने के लिए Ghostscript ज़रूरी है, जो नहीं मिला. Ghostscript इंस्टॉल करें और फिर से कोशिश करें." + +#: rayforge/image/postscript/importer.py +#, python-brace-format +msgid "Ghostscript could not convert {filename}: {error}" +msgstr "Ghostscript {filename} को परिवर्तित नहीं कर सका: {error}" + +#: rayforge/image/postscript/importer.py +msgid "This Illustrator file was saved without PDF compatibility. Rayforge can only read it with Ghostscript, which was not found. Install Ghostscript, or save the file again in Illustrator with \"Create PDF Compatible File\" enabled." +msgstr "यह Illustrator फ़ाइल PDF संगतता के बिना सहेजी गई थी. Rayforge इसे केवल Ghostscript से पढ़ सकता है, जो नहीं मिला. Ghostscript इंस्टॉल करें, या Illustrator में \"Create PDF Compatible File\" चालू करके फ़ाइल फिर से सहेजें." diff --git a/rayforge/locale/pt/LC_MESSAGES/rayforge.po b/rayforge/locale/pt/LC_MESSAGES/rayforge.po index 9bc997263..95cba3547 100644 --- a/rayforge/locale/pt/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/pt/LC_MESSAGES/rayforge.po @@ -10401,3 +10401,16 @@ msgstr "Bayer 8" #, python-brace-format msgid "Could not read the {format} image: {error}" msgstr "Não foi possível ler a imagem {format}: {error}" + +#: rayforge/image/postscript/importer.py +msgid "Importing EPS and PostScript files requires Ghostscript, which was not found. Install Ghostscript and try again." +msgstr "Importar arquivos EPS e PostScript requer o Ghostscript, que não foi encontrado. Instale o Ghostscript e tente novamente." + +#: rayforge/image/postscript/importer.py +#, python-brace-format +msgid "Ghostscript could not convert {filename}: {error}" +msgstr "O Ghostscript não conseguiu converter {filename}: {error}" + +#: rayforge/image/postscript/importer.py +msgid "This Illustrator file was saved without PDF compatibility. Rayforge can only read it with Ghostscript, which was not found. Install Ghostscript, or save the file again in Illustrator with \"Create PDF Compatible File\" enabled." +msgstr "Este arquivo do Illustrator foi salvo sem compatibilidade com PDF. O Rayforge só consegue lê-lo com o Ghostscript, que não foi encontrado. Instale o Ghostscript ou salve o arquivo novamente no Illustrator com a opção \"Criar arquivo compatível com PDF\" ativada." diff --git a/rayforge/locale/rayforge.pot b/rayforge/locale/rayforge.pot index 440c7e7e8..ea594e0c3 100644 --- a/rayforge/locale/rayforge.pot +++ b/rayforge/locale/rayforge.pot @@ -9834,3 +9834,16 @@ msgstr "" #, python-brace-format msgid "Could not read the {format} image: {error}" msgstr "" + +#: rayforge/image/postscript/importer.py +msgid "Importing EPS and PostScript files requires Ghostscript, which was not found. Install Ghostscript and try again." +msgstr "" + +#: rayforge/image/postscript/importer.py +#, python-brace-format +msgid "Ghostscript could not convert {filename}: {error}" +msgstr "" + +#: rayforge/image/postscript/importer.py +msgid "This Illustrator file was saved without PDF compatibility. Rayforge can only read it with Ghostscript, which was not found. Install Ghostscript, or save the file again in Illustrator with \"Create PDF Compatible File\" enabled." +msgstr "" diff --git a/rayforge/locale/uk/LC_MESSAGES/rayforge.po b/rayforge/locale/uk/LC_MESSAGES/rayforge.po index 5f7256e70..f58205a0c 100644 --- a/rayforge/locale/uk/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/uk/LC_MESSAGES/rayforge.po @@ -10361,3 +10361,16 @@ msgstr "Bayer 8" #, python-brace-format msgid "Could not read the {format} image: {error}" msgstr "Не вдалося прочитати зображення {format}: {error}" + +#: rayforge/image/postscript/importer.py +msgid "Importing EPS and PostScript files requires Ghostscript, which was not found. Install Ghostscript and try again." +msgstr "Для імпорту файлів EPS і PostScript потрібен Ghostscript, але його не знайдено. Встановіть Ghostscript і спробуйте ще раз." + +#: rayforge/image/postscript/importer.py +#, python-brace-format +msgid "Ghostscript could not convert {filename}: {error}" +msgstr "Ghostscript не зміг перетворити {filename}: {error}" + +#: rayforge/image/postscript/importer.py +msgid "This Illustrator file was saved without PDF compatibility. Rayforge can only read it with Ghostscript, which was not found. Install Ghostscript, or save the file again in Illustrator with \"Create PDF Compatible File\" enabled." +msgstr "Цей файл Illustrator збережено без сумісності з PDF. Rayforge може прочитати його лише за допомогою Ghostscript, але його не знайдено. Встановіть Ghostscript або збережіть файл в Illustrator ще раз з увімкненим параметром «Створити PDF-сумісний файл»." diff --git a/rayforge/locale/zh_CN/LC_MESSAGES/rayforge.po b/rayforge/locale/zh_CN/LC_MESSAGES/rayforge.po index 790f98ac9..866b96eab 100644 --- a/rayforge/locale/zh_CN/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/zh_CN/LC_MESSAGES/rayforge.po @@ -9962,3 +9962,16 @@ msgstr "Bayer 8" #, python-brace-format msgid "Could not read the {format} image: {error}" msgstr "无法读取 {format} 图像:{error}" + +#: rayforge/image/postscript/importer.py +msgid "Importing EPS and PostScript files requires Ghostscript, which was not found. Install Ghostscript and try again." +msgstr "导入 EPS 和 PostScript 文件需要 Ghostscript,但未找到。请安装 Ghostscript 后重试。" + +#: rayforge/image/postscript/importer.py +#, python-brace-format +msgid "Ghostscript could not convert {filename}: {error}" +msgstr "Ghostscript 无法转换 {filename}:{error}" + +#: rayforge/image/postscript/importer.py +msgid "This Illustrator file was saved without PDF compatibility. Rayforge can only read it with Ghostscript, which was not found. Install Ghostscript, or save the file again in Illustrator with \"Create PDF Compatible File\" enabled." +msgstr "此 Illustrator 文件保存时未启用 PDF 兼容。Rayforge 只能借助 Ghostscript 读取它,但未找到 Ghostscript。请安装 Ghostscript,或在 Illustrator 中启用“创建 PDF 兼容文件”后重新保存该文件。" diff --git a/tests/image/postscript/test_postscript_import.py b/tests/image/postscript/test_postscript_import.py new file mode 100644 index 000000000..4f7d50aca --- /dev/null +++ b/tests/image/postscript/test_postscript_import.py @@ -0,0 +1,245 @@ +import io +import shutil +import subprocess +from pathlib import Path +from typing import cast + +import cairo +import pytest + +from rayforge.core.source_asset import SourceAsset +from rayforge.core.vectorization_spec import PassthroughSpec, TraceSpec +from rayforge.core.workpiece import WorkPiece +from rayforge.image import import_file, importer_registry +from rayforge.image.pdf.renderer import PDF_RENDERER +from rayforge.image.postscript import ghostscript +from rayforge.image.postscript.importer import AiImporter, EpsImporter + +HAS_GS = ghostscript.find_ghostscript() is not None +needs_gs = pytest.mark.skipif(not HAS_GS, reason="Ghostscript not installed") + +PS_DRAWING = b"""newpath 10 10 moveto 134 10 lineto 134 62 lineto 10 62 lineto +closepath 1 setlinewidth stroke +showpage +%%EOF +""" + +EPS_DATA = ( + b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 144 72\n" + b"%%EndComments\n" + PS_DRAWING +) + +LEGACY_AI_DATA = ( + b"%!PS-Adobe-3.0 EPSF-3.0\n%%Creator: Adobe Illustrator(R) 8.0\n" + b"%%BoundingBox: 0 0 144 72\n%%EndComments\n" + PS_DRAWING +) + + +def _pdf_compatible_ai() -> bytes: + """A PDF stream as written by Illustrator with PDF compatibility.""" + buf = io.BytesIO() + surface = cairo.PDFSurface(buf, 144, 72) + cr = cairo.Context(surface) + cr.rectangle(10, 10, 124, 52) + cr.set_line_width(1) + cr.stroke() + surface.finish() + return buf.getvalue() + + +@pytest.fixture +def no_ghostscript(monkeypatch): + monkeypatch.setattr(ghostscript, "find_ghostscript", lambda: None) + ghostscript.clear_cache() + yield + ghostscript.clear_cache() + + +def _source(result) -> SourceAsset: + assert result is not None + assert result.payload is not None, result.errors + return result.payload.source + + +def _items(result) -> list[WorkPiece]: + assert result is not None + assert result.payload is not None, result.errors + return cast(list[WorkPiece], result.payload.items) + + +class TestRegistration: + @pytest.mark.parametrize( + "extension, importer_cls", + [ + (".ai", AiImporter), + (".eps", EpsImporter), + (".epsf", EpsImporter), + (".ps", EpsImporter), + ], + ) + def test_extension_lookup(self, extension, importer_cls): + assert importer_registry.get_by_extension(extension) is importer_cls + + @pytest.mark.parametrize( + "mime_type, importer_cls", + [ + ("application/illustrator", AiImporter), + ("image/x-eps", EpsImporter), + ("application/postscript", EpsImporter), + ], + ) + def test_mime_lookup(self, mime_type, importer_cls): + assert importer_registry.get_by_mime_type(mime_type) is importer_cls + + +class TestIllustrator: + def test_pdf_compatible_file_needs_no_ghostscript(self, no_ghostscript): + data = _pdf_compatible_ai() + importer = AiImporter(data, Path("logo.ai")) + assert importer.scan().errors == [] + + result = AiImporter(data, Path("logo.ai")).get_doc_items( + PassthroughSpec() + ) + items = _items(result) + assert items + assert items[0].boundaries is not None + assert not items[0].boundaries.is_empty() + source = _source(result) + assert source.original_data == data + assert source.renderer is PDF_RENDERER + assert source.metadata["_importer_class"] == "AiImporter" + + def test_pdf_compatible_file_can_be_traced(self, no_ghostscript): + result = AiImporter( + _pdf_compatible_ai(), Path("logo.ai") + ).get_doc_items(TraceSpec()) + assert _items(result) + + def test_legacy_file_without_ghostscript_explains(self, no_ghostscript): + importer = AiImporter(LEGACY_AI_DATA, Path("old.ai")) + errors = importer.scan().errors + assert len(errors) == 1 + assert "PDF" in errors[0] + assert "Ghostscript" in errors[0] + + result = AiImporter(LEGACY_AI_DATA, Path("old.ai")).get_doc_items( + PassthroughSpec() + ) + assert result is not None + assert result.payload is None + assert result.errors + + @needs_gs + def test_legacy_file_is_converted_with_ghostscript(self): + result = AiImporter(LEGACY_AI_DATA, Path("old.ai")).get_doc_items( + PassthroughSpec() + ) + assert _items(result) + assert _source(result).original_data.startswith(b"%PDF") + + def test_import_file_picks_illustrator_importer( + self, tmp_path, no_ghostscript + ): + path = tmp_path / "logo.ai" + path.write_bytes(_pdf_compatible_ai()) + payload = import_file(path, mime_type="application/illustrator") + assert payload is not None + assert payload.source.metadata["_importer_class"] == "AiImporter" + + +class TestEps: + def test_without_ghostscript_reports_missing_dependency( + self, no_ghostscript + ): + manifest = EpsImporter(EPS_DATA, Path("logo.eps")).scan() + assert len(manifest.errors) == 1 + assert "Ghostscript" in manifest.errors[0] + + result = EpsImporter(EPS_DATA, Path("logo.eps")).get_doc_items( + PassthroughSpec() + ) + assert result is not None + assert result.payload is None + assert any("Ghostscript" in e for e in result.errors) + + @needs_gs + def test_scan_uses_bounding_box(self): + manifest = EpsImporter(EPS_DATA, Path("logo.eps")).scan() + assert manifest.errors == [] + assert manifest.natural_size_mm == pytest.approx( + (144 * 25.4 / 72, 72 * 25.4 / 72), abs=0.5 + ) + + @needs_gs + def test_vectors_are_imported(self): + result = EpsImporter(EPS_DATA, Path("logo.eps")).get_doc_items( + PassthroughSpec() + ) + items = _items(result) + assert len(items) >= 1 + wp = items[0] + assert wp.boundaries is not None + assert not wp.boundaries.is_empty() + width_mm, height_mm = wp.size + assert width_mm == pytest.approx(144 * 25.4 / 72, abs=0.5) + assert height_mm == pytest.approx(72 * 25.4 / 72, abs=0.5) + + source = _source(result) + assert source.original_data.startswith(b"%PDF") + assert source.source_file == Path("logo.eps") + assert source.renderer is PDF_RENDERER + assert source.metadata["_importer_class"] == "EpsImporter" + + @needs_gs + def test_reimport_does_not_need_ghostscript(self, monkeypatch): + result = EpsImporter(EPS_DATA, Path("logo.eps")).get_doc_items( + PassthroughSpec() + ) + source = _source(result) + monkeypatch.setattr(ghostscript, "find_ghostscript", lambda: None) + ghostscript.clear_cache() + + importer = EpsImporter(source.original_data, Path("logo.eps")) + again = importer.get_doc_items_for_reimport(source, PassthroughSpec()) + assert again is not None + assert again.errors == [] + + @needs_gs + def test_broken_file_reports_conversion_error(self): + broken = b"%!PS-Adobe-3.0\nthis is not postscript {{{\n" + manifest = EpsImporter(broken, Path("broken.eps")).scan() + assert len(manifest.errors) == 1 + assert "broken.eps" in manifest.errors[0] + + +class TestGhostscript: + def test_conversion_is_cached(self, monkeypatch): + calls = [] + + def fake_run(cmd, **kwargs): + calls.append(cmd) + out = next(a for a in cmd if a.startswith("-sOutputFile=")) + Path(out.split("=", 1)[1]).write_bytes(b"%PDF-1.4 fake") + return subprocess.CompletedProcess(cmd, 0, b"", b"") + + monkeypatch.setattr(ghostscript, "find_ghostscript", lambda: "gs") + monkeypatch.setattr(ghostscript.subprocess, "run", fake_run) + ghostscript.clear_cache() + + first = ghostscript.convert_to_pdf(EPS_DATA) + second = ghostscript.convert_to_pdf(EPS_DATA) + assert first == second == b"%PDF-1.4 fake" + assert len(calls) == 1 + assert "-dSAFER" in calls[0] + assert "-dNoOutputFonts" in calls[0] + ghostscript.clear_cache() + + def test_missing_ghostscript_raises(self, no_ghostscript): + with pytest.raises(ghostscript.GhostscriptNotFound): + ghostscript.convert_to_pdf(EPS_DATA) + + def test_finds_windows_console_binary(self, monkeypatch): + found = {"gswin64c": "C:/gs/bin/gswin64c.exe"} + monkeypatch.setattr(shutil, "which", lambda name: found.get(name)) + assert ghostscript.find_ghostscript() == "C:/gs/bin/gswin64c.exe" diff --git a/website/docs/files/formats.md b/website/docs/files/formats.md index 1620bf0c0..b1f9d916f 100644 --- a/website/docs/files/formats.md +++ b/website/docs/files/formats.md @@ -18,6 +18,8 @@ capabilities, limitations, and recommendations. | **SVG** | Vector | ✓ Direct / Trace | ✓ Object export | Primary design format | | **DXF** | Vector | ✓ Direct | ✓ Object export | CAD interchange | | **PDF** | Mixed | ✓ Direct / Trace | – | Documents with vector content | +| **Illustrator** | Vector | ✓ Direct / Trace | – | Logos from Illustrator | +| **EPS** | Vector | ✓ Direct / Trace | – | Logos, print artwork | | **LightBurn** | Vector | ✓ Direct | – | LightBurn project files | | **Ruida** | Vector | ✓ Direct | – | Ruida controller job files | | **PNG** | Raster | ✓ Trace | – | Photos, images | @@ -223,6 +225,36 @@ and tracing. --- +### Adobe Illustrator (.ai) + +**Extension:** `.ai` **MIME Type:** `application/illustrator` **Import:** Direct vectors or +render-and-trace **Export:** Not supported + +Since version 9, Illustrator saves its files with an embedded PDF by default (the **Create PDF +Compatible File** option). Rayforge reads that PDF, so these files import just like a PDF, layers +included. + +Older Illustrator files, and files saved without PDF compatibility, are PostScript. Rayforge +converts them with Ghostscript when it is installed. Otherwise the import dialog explains how to +save the file again. + +--- + +### EPS and PostScript (.eps / .ps) + +**Extension:** `.eps`, `.epsf`, `.ps` **MIME Type:** `image/x-eps`, `application/postscript` +**Import:** Direct vectors or render-and-trace, through Ghostscript **Export:** Not supported + +Rayforge converts EPS and PostScript files to PDF with Ghostscript and then imports them like a PDF. +The EPS bounding box becomes the page size, and text is converted to outlines. + +**Ghostscript** is an optional dependency. On Linux, install your distribution's `ghostscript` +package. On Windows and macOS, install it from the Ghostscript website. Without Ghostscript, the +import dialog shows a message instead of a preview. The project stores the converted PDF, so a saved +project also opens on a computer without Ghostscript. + +--- + ## Raster Formats All raster formats are **imported by tracing** - converted to vector paths automatically. diff --git a/website/docs/files/importing.md b/website/docs/files/importing.md index 27bd47687..17922e2ee 100644 --- a/website/docs/files/importing.md +++ b/website/docs/files/importing.md @@ -13,13 +13,15 @@ import files and optimize them for best results. ### Vector Formats -| Format | Extension | Import Method | Best For | -| ------------- | ----------------- | ----------------------- | ------------------------------- | -| **SVG** | `.svg` | Direct vectors or trace | Vector graphics, logos, designs | -| **DXF** | `.dxf` | Direct vectors | CAD drawings, technical designs | -| **PDF** | `.pdf` | Direct vectors or trace | Documents with vector content | -| **LightBurn** | `.lbrn`, `.lbrn2` | Direct vectors | LightBurn project files | -| **Ruida** | `.rd` | Direct vectors | Ruida controller job files | +| Format | Extension | Import Method | Best For | +| --------------- | ----------------- | ------------------------------------------- | ------------------------------- | +| **SVG** | `.svg` | Direct vectors or trace | Vector graphics, logos, designs | +| **DXF** | `.dxf` | Direct vectors | CAD drawings, technical designs | +| **PDF** | `.pdf` | Direct vectors or trace | Documents with vector content | +| **LightBurn** | `.lbrn`, `.lbrn2` | Direct vectors | LightBurn project files | +| **Ruida** | `.rd` | Direct vectors | Ruida controller job files | +| **Illustrator** | `.ai` | Direct vectors or trace | Logos from Illustrator | +| **EPS** | `.eps`, `.ps` | Direct vectors or trace (needs Ghostscript) | Logos, print artwork | ### Raster Formats @@ -354,6 +356,17 @@ settings. --- +## Illustrator and EPS Import + +Illustrator files saved with PDF compatibility, the default since Illustrator 9, import exactly like +PDF files, with the same layer selection and tracing options. + +EPS, PostScript and older Illustrator files are converted to PDF with Ghostscript first. Install +Ghostscript if the import dialog reports that it is missing. If you cannot install it, export the +design as PDF or SVG from the program that made it. + +--- + ## Raster Image Import (PNG, JPG, BMP, GIF, TIFF, WebP) Raster images are **traced** to create vector paths using the import dialog. diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/files/formats.md b/website/i18n/de/docusaurus-plugin-content-docs/current/files/formats.md index 8a836e6fe..ed317a86a 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/files/formats.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/files/formats.md @@ -12,6 +12,8 @@ einschließlich Fähigkeiten, Einschränkungen und Empfehlungen. | **SVG** | Vektor | ✓ Direkt / Tracing | ✓ Objekt-Export | Primäres Design-Format | | **DXF** | Vektor | ✓ Direkt | ✓ Objekt-Export | CAD-Datenaustausch | | **PDF** | Gemischt | ✓ Direkt / Tracing | – | Dokumente mit Vektorinhalt | +| **Illustrator** | Vektor | ✓ Direkt / Tracing | – | Logos aus Illustrator | +| **EPS** | Vektor | ✓ Direkt / Tracing | – | Logos, Druckvorlagen | | **LightBurn** | Vektor | ✓ Direkt | – | LightBurn-Projekte | | **Ruida** | Vektor | ✓ Direkt | – | Ruida-Aufgabendateien | | **PNG** | Raster | ✓ Tracing | – | Fotos, Bilder | @@ -218,6 +220,38 @@ Tracing zurück. --- +### Adobe Illustrator (.ai) + +**Erweiterung:** `.ai` **MIME-Typ:** `application/illustrator` **Import:** Direkte Vektoren oder +Rendern und Tracing **Export:** Nicht unterstützt + +Seit Version 9 speichert Illustrator seine Dateien standardmäßig mit einem eingebetteten PDF (Option +**PDF-kompatible Datei erstellen**). Rayforge liest dieses PDF, solche Dateien werden also genau wie +ein PDF importiert, einschließlich Ebenen. + +Ältere Illustrator-Dateien und Dateien, die ohne PDF-Kompatibilität gespeichert wurden, sind +PostScript. Rayforge wandelt sie mit Ghostscript um, wenn es installiert ist. Andernfalls erklärt +der Importdialog, wie du die Datei erneut speichern kannst. + +--- + +### EPS und PostScript (.eps / .ps) + +**Erweiterung:** `.eps`, `.epsf`, `.ps` **MIME-Typ:** `image/x-eps`, `application/postscript` +**Import:** Direkte Vektoren oder Rendern und Tracing, über Ghostscript **Export:** Nicht +unterstützt + +Rayforge wandelt EPS- und PostScript-Dateien mit Ghostscript in PDF um und importiert sie dann wie +ein PDF. Die EPS-Bounding-Box wird zur Seitengröße, und Text wird in Konturen umgewandelt. + +**Ghostscript** ist eine optionale Abhängigkeit. Unter Linux installierst du das Paket `ghostscript` +deiner Distribution, unter Windows und macOS die Version von der Ghostscript-Website. Ohne +Ghostscript zeigt der Importdialog statt einer Vorschau einen Hinweis. Das Projekt speichert das +umgewandelte PDF, daher lässt sich ein gespeichertes Projekt auch auf einem Rechner ohne Ghostscript +öffnen. + +--- + ## Rasterformate Alle Rasterformate werden **durch Tracing importiert** - automatisch in Vektorpfade konvertiert. diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/files/importing.md b/website/i18n/de/docusaurus-plugin-content-docs/current/files/importing.md index bd951d7ca..2671909ea 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/files/importing.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/files/importing.md @@ -7,13 +7,15 @@ Rasterformate. Diese Seite erklärt, wie du Dateien importierst und für beste E ### Vektorformate -| Format | Erweiterung | Importmethode | Am besten für | -| ------------- | ----------------- | ----------------------------- | ----------------------------------- | -| **SVG** | `.svg` | Direkte Vektoren oder Tracing | Vektorgrafiken, Logos, Designs | -| **DXF** | `.dxf` | Direkte Vektoren | CAD-Zeichnungen, technische Designs | -| **PDF** | `.pdf` | Direkte Vektoren oder Tracing | Dokumente mit Vektorinhalt | -| **LightBurn** | `.lbrn`, `.lbrn2` | Direkte Vektoren | LightBurn-Projekte | -| **Ruida** | `.rd` | Direkte Vektoren | Ruida-Controller-Auftragsdateien | +| Format | Erweiterung | Importmethode | Am besten für | +| --------------- | ----------------- | ---------------------------------------------------- | ----------------------------------- | +| **SVG** | `.svg` | Direkte Vektoren oder Tracing | Vektorgrafiken, Logos, Designs | +| **DXF** | `.dxf` | Direkte Vektoren | CAD-Zeichnungen, technische Designs | +| **PDF** | `.pdf` | Direkte Vektoren oder Tracing | Dokumente mit Vektorinhalt | +| **LightBurn** | `.lbrn`, `.lbrn2` | Direkte Vektoren | LightBurn-Projekte | +| **Ruida** | `.rd` | Direkte Vektoren | Ruida-Controller-Auftragsdateien | +| **Illustrator** | `.ai` | Direkte Vektoren oder Tracing | Logos aus Illustrator | +| **EPS** | `.eps`, `.ps` | Direkte Vektoren oder Tracing (benötigt Ghostscript) | Logos, Druckvorlagen | ### Rasterformate @@ -360,6 +362,17 @@ farbcodierten Ebenen mit konfigurierbaren Laser-Einstellungen organisiert ist. --- +## Illustrator- und EPS-Import + +Illustrator-Dateien, die mit PDF-Kompatibilität gespeichert wurden (Standard seit Illustrator 9), +werden genau wie PDF-Dateien importiert, mit derselben Ebenenauswahl und denselben Tracing-Optionen. + +EPS-, PostScript- und ältere Illustrator-Dateien werden zuerst mit Ghostscript in PDF umgewandelt. +Installiere Ghostscript, wenn der Importdialog meldet, dass es fehlt. Wenn du es nicht installieren +kannst, exportiere das Design im Ursprungsprogramm als PDF oder SVG. + +--- + ## Rasterbild-Import (PNG, JPG, BMP, GIF, TIFF, WebP) Rasterbilder werden **traced**, um Vektorpfade über den Import-Dialog zu erstellen. diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/files/formats.md b/website/i18n/es/docusaurus-plugin-content-docs/current/files/formats.md index 67ce00251..9a68f3737 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/files/formats.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/files/formats.md @@ -7,22 +7,24 @@ Rayforge, incluyendo capacidades, limitaciones y recomendaciones. ### Referencia rápida -| Formato | Tipo | Importar | Exportar | Uso recomendado | -| --------------------- | -------- | ----------------- | --------------- | ----------------------------- | -| **SVG** | Vector | ✓ Directo / Traza | ✓ Exportar obj. | Formato de diseño principal | -| **DXF** | Vector | ✓ Directo | ✓ Exportar obj. | Intercambio CAD | -| **PDF** | Mixto | ✓ Directo / Traza | – | Documentos con contenido vec. | -| **LightBurn** | Vector | ✓ Directo | – | Proyectos LightBurn | -| **Ruida** | Vector | ✓ Directo | – | Archivos de trabajo Ruida | -| **PNG** | Raster | ✓ Traza | – | Fotos, imágenes | -| **JPEG** | Raster | ✓ Traza | – | Fotos | -| **BMP** | Raster | ✓ Traza | – | Gráficos simples | -| **GIF** | Raster | ✓ Traza | – | Gráficos simples, animaciones | -| **TIFF** | Raster | ✓ Traza | – | Escaneos, arte para impresión | -| **WebP** | Raster | ✓ Traza | – | Imágenes web | -| **RFS** | Bosquejo | ✓ Directo | ✓ Exportar obj. | Bosquejos paramétricos | -| **G-code** | Control | – | ✓ Principal | Salida de máquina | -| **Proyecto Rayforge** | Proyecto | ✓ | ✓ | Guardar/cargar proyectos | +| Formato | Tipo | Importar | Exportar | Uso recomendado | +| --------------------- | -------- | ----------------- | --------------- | ------------------------------ | +| **SVG** | Vector | ✓ Directo / Traza | ✓ Exportar obj. | Formato de diseño principal | +| **DXF** | Vector | ✓ Directo | ✓ Exportar obj. | Intercambio CAD | +| **PDF** | Mixto | ✓ Directo / Traza | – | Documentos con contenido vec. | +| **Illustrator** | Vector | ✓ Directo / Traza | – | Logotipos de Illustrator | +| **EPS** | Vector | ✓ Directo / Traza | – | Logotipos, arte para impresión | +| **LightBurn** | Vector | ✓ Directo | – | Proyectos LightBurn | +| **Ruida** | Vector | ✓ Directo | – | Archivos de trabajo Ruida | +| **PNG** | Raster | ✓ Traza | – | Fotos, imágenes | +| **JPEG** | Raster | ✓ Traza | – | Fotos | +| **BMP** | Raster | ✓ Traza | – | Gráficos simples | +| **GIF** | Raster | ✓ Traza | – | Gráficos simples, animaciones | +| **TIFF** | Raster | ✓ Traza | – | Escaneos, arte para impresión | +| **WebP** | Raster | ✓ Traza | – | Imágenes web | +| **RFS** | Bosquejo | ✓ Directo | ✓ Exportar obj. | Bosquejos paramétricos | +| **G-code** | Control | – | ✓ Principal | Salida de máquina | +| **Proyecto Rayforge** | Proyecto | ✓ | ✓ | Guardar/cargar proyectos | --- @@ -219,6 +221,38 @@ renderizado y trazado. --- +### Adobe Illustrator (.ai) + +**Extensión:** `.ai` **Tipo MIME:** `application/illustrator` **Importación:** Vectores directos o +renderizado y trazo **Exportación:** No soportado + +Desde la versión 9, Illustrator guarda sus archivos con un PDF incrustado por defecto (la opción +**Crear archivo compatible con PDF**). Rayforge lee ese PDF, así que estos archivos se importan +igual que un PDF, capas incluidas. + +Los archivos de Illustrator más antiguos, y los guardados sin compatibilidad con PDF, son +PostScript. Rayforge los convierte con Ghostscript cuando está instalado. Si no, el diálogo de +importación explica cómo volver a guardar el archivo. + +--- + +### EPS y PostScript (.eps / .ps) + +**Extensión:** `.eps`, `.epsf`, `.ps` **Tipo MIME:** `image/x-eps`, `application/postscript` +**Importación:** Vectores directos o renderizado y trazo, mediante Ghostscript **Exportación:** No +soportado + +Rayforge convierte los archivos EPS y PostScript a PDF con Ghostscript y luego los importa como un +PDF. El cuadro delimitador del EPS pasa a ser el tamaño de página y el texto se convierte en +contornos. + +**Ghostscript** es una dependencia opcional. En Linux, instala el paquete `ghostscript` de tu +distribución. En Windows y macOS, instálalo desde el sitio web de Ghostscript. Sin Ghostscript, el +diálogo de importación muestra un mensaje en lugar de una vista previa. El proyecto guarda el PDF +convertido, así que un proyecto guardado también se abre en un equipo sin Ghostscript. + +--- + ## Formatos ráster Todos los formatos ráster se **importan mediante trazado** — se convierten automáticamente a diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/files/importing.md b/website/i18n/es/docusaurus-plugin-content-docs/current/files/importing.md index 3ffdfd698..c05ea7a02 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/files/importing.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/files/importing.md @@ -7,13 +7,15 @@ bits. Esta página explica cómo importar archivos y optimizarlos para obtener l ### Formatos vectoriales -| Formato | Extensión | Método de importación | Mejor para | -| ------------- | ----------------- | ------------------------- | ------------------------------------- | -| **SVG** | `.svg` | Vectores directos o trazo | Gráficos vectoriales, logos, diseños | -| **DXF** | `.dxf` | Vectores directos | Planos CAD, diseños técnicos | -| **PDF** | `.pdf` | Vectores directos o trazo | Documentos con contenido vectorial | -| **LightBurn** | `.lbrn`, `.lbrn2` | Vectores directos | Proyectos LightBurn | -| **Ruida** | `.rd` | Vectores directos | Archivos de trabajo controlador Ruida | +| Formato | Extensión | Método de importación | Mejor para | +| --------------- | ----------------- | ------------------------------------------------ | ------------------------------------- | +| **SVG** | `.svg` | Vectores directos o trazo | Gráficos vectoriales, logos, diseños | +| **DXF** | `.dxf` | Vectores directos | Planos CAD, diseños técnicos | +| **PDF** | `.pdf` | Vectores directos o trazo | Documentos con contenido vectorial | +| **LightBurn** | `.lbrn`, `.lbrn2` | Vectores directos | Proyectos LightBurn | +| **Ruida** | `.rd` | Vectores directos | Archivos de trabajo controlador Ruida | +| **Illustrator** | `.ai` | Vectores directos o trazo | Logotipos de Illustrator | +| **EPS** | `.eps`, `.ps` | Vectores directos o trazo (requiere Ghostscript) | Logotipos, arte para impresión | ### Formatos de mapa de bits @@ -358,6 +360,18 @@ configuraciones láser ajustables. --- +## Importación de Illustrator y EPS + +Los archivos de Illustrator guardados con compatibilidad con PDF, lo predeterminado desde +Illustrator 9, se importan igual que los archivos PDF, con la misma selección de capas y las mismas +opciones de trazo. + +Los archivos EPS, PostScript y los de Illustrator más antiguos se convierten primero a PDF con +Ghostscript. Instala Ghostscript si el diálogo de importación indica que falta. Si no puedes +instalarlo, exporta el diseño como PDF o SVG desde el programa que lo creó. + +--- + ## Importación de imágenes de mapa de bits (PNG, JPG, BMP, GIF, TIFF, WebP) Las imágenes de mapa de bits se **trazan** para crear trazados vectoriales utilizando el diálogo de diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/files/formats.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/files/formats.md index b030a9109..fe201c72f 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/files/formats.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/files/formats.md @@ -12,6 +12,8 @@ Rayforge, y compris les capacités, les limitations et les recommandations. | **SVG** | Vecteur | ✓ Direct / Trace | ✓ Export obj. | Format de conception principal | | **DXF** | Vecteur | ✓ Direct | ✓ Export obj. | Échange CAO | | **PDF** | Mixte | ✓ Direct / Trace | – | Documents avec contenu vectoriel | +| **Illustrator** | Vecteur | ✓ Direct / Trace | – | Logos issus d'Illustrator | +| **EPS** | Vecteur | ✓ Direct / Trace | – | Logos, fichiers d'impression | | **LightBurn** | Vecteur | ✓ Direct | – | Projets LightBurn | | **Ruida** | Vecteur | ✓ Direct | – | Fichiers de tâches Ruida | | **PNG** | Matriciel | ✓ Trace | – | Photos, images | @@ -221,6 +223,38 @@ rendu et au tracé. --- +### Adobe Illustrator (.ai) + +**Extension :** `.ai` **Type MIME :** `application/illustrator` **Importation :** Vecteurs directs +ou rendu et vectorisation **Exportation :** Non pris en charge + +Depuis la version 9, Illustrator enregistre par défaut ses fichiers avec un PDF intégré (option +**Créer un fichier compatible PDF**). Rayforge lit ce PDF : ces fichiers s'importent donc comme un +PDF, calques compris. + +Les fichiers Illustrator plus anciens, et ceux enregistrés sans compatibilité PDF, sont en +PostScript. Rayforge les convertit avec Ghostscript lorsqu'il est installé. Sinon, la boîte de +dialogue d'importation explique comment enregistrer à nouveau le fichier. + +--- + +### EPS et PostScript (.eps / .ps) + +**Extension :** `.eps`, `.epsf`, `.ps` **Type MIME :** `image/x-eps`, `application/postscript` +**Importation :** Vecteurs directs ou rendu et vectorisation, via Ghostscript **Exportation :** Non +pris en charge + +Rayforge convertit les fichiers EPS et PostScript en PDF avec Ghostscript, puis les importe comme un +PDF. La boîte englobante de l'EPS devient la taille de la page et le texte est converti en contours. + +**Ghostscript** est une dépendance facultative. Sous Linux, installez le paquet `ghostscript` de +votre distribution ; sous Windows et macOS, installez-le depuis le site de Ghostscript. Sans +Ghostscript, la boîte de dialogue d'importation affiche un message au lieu d'un aperçu. Le projet +enregistre le PDF converti : un projet enregistré s'ouvre donc aussi sur un ordinateur sans +Ghostscript. + +--- + ## Formats matriciels Tous les formats matriciels sont **importés par tracé** — convertis automatiquement en chemins diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/files/importing.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/files/importing.md index 9c1a8eb02..33428236e 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/files/importing.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/files/importing.md @@ -8,13 +8,15 @@ meilleurs résultats. ### Formats vectoriels -| Format | Extension | Méthode d'importation | Idéal pour | -| ------------- | ----------------- | --------------------------------- | ------------------------------------- | -| **SVG** | `.svg` | Vecteurs directs ou vectorisation | Graphiques vectoriels, logos, dessins | -| **DXF** | `.dxf` | Vecteurs directs | Dessins CAO, plans techniques | -| **PDF** | `.pdf` | Vecteurs directs ou vectorisation | Documents avec contenu vectoriel | -| **LightBurn** | `.lbrn`, `.lbrn2` | Vecteurs directs | Projets LightBurn | -| **Ruida** | `.rd` | Vecteurs directs | Fichiers de tâches contrôleur Ruida | +| Format | Extension | Méthode d'importation | Idéal pour | +| --------------- | ----------------- | --------------------------------------------------------- | ------------------------------------- | +| **SVG** | `.svg` | Vecteurs directs ou vectorisation | Graphiques vectoriels, logos, dessins | +| **DXF** | `.dxf` | Vecteurs directs | Dessins CAO, plans techniques | +| **PDF** | `.pdf` | Vecteurs directs ou vectorisation | Documents avec contenu vectoriel | +| **LightBurn** | `.lbrn`, `.lbrn2` | Vecteurs directs | Projets LightBurn | +| **Ruida** | `.rd` | Vecteurs directs | Fichiers de tâches contrôleur Ruida | +| **Illustrator** | `.ai` | Vecteurs directs ou vectorisation | Logos issus d'Illustrator | +| **EPS** | `.eps`, `.ps` | Vecteurs directs ou vectorisation (nécessite Ghostscript) | Logos, fichiers d'impression | ### Formats matriciels @@ -365,6 +367,18 @@ colorés avec des paramètres laser configurables. --- +## Importation d'Illustrator et d'EPS + +Les fichiers Illustrator enregistrés avec la compatibilité PDF, le réglage par défaut depuis +Illustrator 9, s'importent exactement comme des fichiers PDF, avec la même sélection de calques et +les mêmes options de vectorisation. + +Les fichiers EPS, PostScript et Illustrator plus anciens sont d'abord convertis en PDF avec +Ghostscript. Installez Ghostscript si la boîte de dialogue d'importation indique qu'il manque. Si +vous ne pouvez pas l'installer, exportez le dessin en PDF ou en SVG depuis le logiciel qui l'a créé. + +--- + ## Importation d'images matricielles (PNG, JPG, BMP, GIF, TIFF, WebP) Les images matricielles sont **vectorisées** pour créer des tracés vectoriels via la boîte de diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/files/formats.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/files/formats.md index 06b3a63d1..c43061263 100644 --- a/website/i18n/hi/docusaurus-plugin-content-docs/current/files/formats.md +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/files/formats.md @@ -18,6 +18,8 @@ description: | **SVG** | वेक्टर | ✓ Direct / Trace | ✓ Object export | प्राथमिक डिज़ाइन फ़ॉर्मेट | | **DXF** | वेक्टर | ✓ Direct | ✓ Object export | CAD इंटरचेंज | | **PDF** | मिश्रित | ✓ Direct / Trace | – | वेक्टर सामग्री युक्त दस्तावेज़ | +| **Illustrator** | वेक्टर | ✓ Direct / Trace | – | Illustrator से लोगो | +| **EPS** | वेक्टर | ✓ Direct / Trace | – | लोगो, प्रिंट आर्टवर्क | | **LightBurn** | वेक्टर | ✓ Direct | – | LightBurn परियोजना फ़ाइलें | | **Ruida** | वेक्टर | ✓ Direct | – | Ruida नियंत्रक जॉब फ़ाइलें | | **PNG** | रास्टर | ✓ Trace | – | फ़ोटो, छवियाँ | @@ -208,6 +210,36 @@ PDF फ़ाइलों में वास्तविक वेक्टर --- +### Adobe Illustrator (.ai) + +**एक्सटेंशन:** `.ai` **MIME प्रकार:** `application/illustrator` **आयात:** सीधे वेक्टर या रेंडर करके +ट्रेस **निर्यात:** समर्थित नहीं + +संस्करण 9 से Illustrator अपनी फ़ाइलें डिफ़ॉल्ट रूप से एक एम्बेडेड PDF के साथ सहेजता है (**Create PDF +Compatible File** विकल्प). Rayforge उस PDF को पढ़ता है, इसलिए ये फ़ाइलें लेयर सहित बिल्कुल PDF की +तरह आयात होती हैं. + +पुरानी Illustrator फ़ाइलें, और PDF संगतता के बिना सहेजी गई फ़ाइलें, PostScript होती हैं. Ghostscript +इंस्टॉल होने पर Rayforge उन्हें परिवर्तित करता है. नहीं तो आयात डायलॉग बताता है कि फ़ाइल को फिर से +कैसे सहेजें. + +--- + +### EPS और PostScript (.eps / .ps) + +**एक्सटेंशन:** `.eps`, `.epsf`, `.ps` **MIME प्रकार:** `image/x-eps`, `application/postscript` +**आयात:** Ghostscript के ज़रिए सीधे वेक्टर या रेंडर करके ट्रेस **निर्यात:** समर्थित नहीं + +Rayforge EPS और PostScript फ़ाइलों को Ghostscript से PDF में बदलता है और फिर उन्हें PDF की तरह आयात +करता है. EPS बाउंडिंग बॉक्स पृष्ठ का आकार बन जाता है, और टेक्स्ट आउटलाइन में बदल जाता है. + +**Ghostscript** एक वैकल्पिक निर्भरता है. Linux पर अपने डिस्ट्रीब्यूशन का `ghostscript` पैकेज इंस्टॉल +करें. Windows और macOS पर इसे Ghostscript की वेबसाइट से इंस्टॉल करें. Ghostscript के बिना आयात +डायलॉग पूर्वावलोकन के बजाय एक संदेश दिखाता है. प्रोजेक्ट परिवर्तित PDF को सहेजता है, इसलिए सहेजा गया +प्रोजेक्ट बिना Ghostscript वाले कंप्यूटर पर भी खुलता है. + +--- + ## रास्टर फ़ॉर्मेट सभी रास्टर फ़ॉर्मेट **ट्रेस करके आयात** होते हैं - स्वतः वेक्टर पाथ में बदले जाते हैं. diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/files/importing.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/files/importing.md index 68e25cc63..ca814d0ca 100644 --- a/website/i18n/hi/docusaurus-plugin-content-docs/current/files/importing.md +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/files/importing.md @@ -13,13 +13,15 @@ Rayforge विभिन्न फ़ाइल फ़ॉर्मेट आय ### वेक्टर फ़ॉर्मेट -| फ़ॉर्मेट | एक्सटेंशन | आयात विधि | सर्वोत्तम के लिए | -| ------------- | ----------------- | -------------------- | -------------------------------- | -| **SVG** | `.svg` | सीधे वेक्टर या ट्रेस | वेक्टर ग्राफ़िक्स, लोगो, डिज़ाइन | -| **DXF** | `.dxf` | सीधे वेक्टर | CAD चित्र, तकनीकी डिज़ाइन | -| **PDF** | `.pdf` | सीधे वेक्टर या ट्रेस | वेक्टर सामग्री युक्त दस्तावेज़ | -| **LightBurn** | `.lbrn`, `.lbrn2` | सीधे वेक्टर | LightBurn परियोजना फ़ाइलें | -| **Ruida** | `.rd` | सीधे वेक्टर | Ruida नियंत्रक जॉब फ़ाइलें | +| फ़ॉर्मेट | एक्सटेंशन | आयात विधि | सर्वोत्तम के लिए | +| --------------- | ----------------- | ----------------------------------------- | -------------------------------- | +| **SVG** | `.svg` | सीधे वेक्टर या ट्रेस | वेक्टर ग्राफ़िक्स, लोगो, डिज़ाइन | +| **DXF** | `.dxf` | सीधे वेक्टर | CAD चित्र, तकनीकी डिज़ाइन | +| **PDF** | `.pdf` | सीधे वेक्टर या ट्रेस | वेक्टर सामग्री युक्त दस्तावेज़ | +| **LightBurn** | `.lbrn`, `.lbrn2` | सीधे वेक्टर | LightBurn परियोजना फ़ाइलें | +| **Ruida** | `.rd` | सीधे वेक्टर | Ruida नियंत्रक जॉब फ़ाइलें | +| **Illustrator** | `.ai` | सीधे वेक्टर या ट्रेस | Illustrator से लोगो | +| **EPS** | `.eps`, `.ps` | सीधे वेक्टर या ट्रेस (Ghostscript ज़रूरी) | लोगो, प्रिंट आर्टवर्क | ### रास्टर फ़ॉर्मेट @@ -354,6 +356,17 @@ LightBurn (.lbrn / .lbrn2) फ़ाइलें LightBurn लेज़र क --- +## Illustrator और EPS आयात + +PDF संगतता के साथ सहेजी गई Illustrator फ़ाइलें, जो Illustrator 9 से डिफ़ॉल्ट है, बिल्कुल PDF फ़ाइलों +की तरह आयात होती हैं, उसी लेयर चयन और ट्रेसिंग विकल्पों के साथ. + +EPS, PostScript और पुरानी Illustrator फ़ाइलें पहले Ghostscript से PDF में बदली जाती हैं. अगर आयात +डायलॉग बताए कि Ghostscript नहीं है, तो उसे इंस्टॉल करें. अगर आप उसे इंस्टॉल नहीं कर सकते, तो डिज़ाइन +को उसे बनाने वाले प्रोग्राम से PDF या SVG के रूप में निर्यात करें. + +--- + ## रास्टर छवि आयात (PNG, JPG, BMP, GIF, TIFF, WebP) रास्टर छवियों को आयात संवाद का उपयोग करके वेक्टर पाथ बनाने के लिए **ट्रेस** किया जाता है. diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/files/formats.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/files/formats.md index d4f889629..dc4d214c4 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/files/formats.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/files/formats.md @@ -12,6 +12,8 @@ Rayforge, incluindo capacidades, limitações e recomendações. | **SVG** | Vetor | ✓ Direto / Traçar | ✓ Exportar obj. | Formato de design principal | | **DXF** | Vetor | ✓ Direto | ✓ Exportar obj. | Intercâmbio CAD | | **PDF** | Misto | ✓ Direto / Traçar | – | Documentos com conteúdo vet. | +| **Illustrator** | Vetor | ✓ Direto / Traçar | – | Logotipos do Illustrator | +| **EPS** | Vetor | ✓ Direto / Traçar | – | Logotipos, arte para impressão | | **LightBurn** | Vetor | ✓ Direto | – | Projetos LightBurn | | **Ruida** | Vetor | ✓ Direto | – | Arquivos de trabalho Ruida | | **PNG** | Raster | ✓ Traçar | – | Fotos, imagens | @@ -220,6 +222,37 @@ renderização e ao traçado. --- +### Adobe Illustrator (.ai) + +**Extensão:** `.ai` **Tipo MIME:** `application/illustrator` **Importação:** Vetores diretos ou +renderização e vetorização **Exportação:** Não suportado + +Desde a versão 9, o Illustrator salva seus arquivos com um PDF incorporado por padrão (a opção +**Criar arquivo compatível com PDF**). O Rayforge lê esse PDF, então esses arquivos são importados +como um PDF, incluindo as camadas. + +Arquivos mais antigos do Illustrator, e arquivos salvos sem compatibilidade com PDF, são PostScript. +O Rayforge os converte com o Ghostscript quando ele está instalado. Caso contrário, a caixa de +diálogo de importação explica como salvar o arquivo novamente. + +--- + +### EPS e PostScript (.eps / .ps) + +**Extensão:** `.eps`, `.epsf`, `.ps` **Tipo MIME:** `image/x-eps`, `application/postscript` +**Importação:** Vetores diretos ou renderização e vetorização, via Ghostscript **Exportação:** Não +suportado + +O Rayforge converte arquivos EPS e PostScript em PDF com o Ghostscript e depois os importa como um +PDF. A caixa delimitadora do EPS vira o tamanho da página, e o texto é convertido em contornos. + +O **Ghostscript** é uma dependência opcional. No Linux, instale o pacote `ghostscript` da sua +distribuição. No Windows e no macOS, instale-o pelo site do Ghostscript. Sem o Ghostscript, a caixa +de diálogo de importação mostra uma mensagem em vez de uma prévia. O projeto guarda o PDF +convertido, então um projeto salvo também abre em um computador sem Ghostscript. + +--- + ## Formatos raster Todos os formatos raster são **importados por traçado** — convertidos automaticamente em caminhos diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/files/importing.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/files/importing.md index 993f66e8a..58b8213e3 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/files/importing.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/files/importing.md @@ -7,13 +7,15 @@ explica como importar arquivos e otimizá-los para obter os melhores resultados. ### Formatos vetoriais -| Formato | Extensão | Método de importação | Ideal para | -| ------------- | ----------------- | ------------------------------ | -------------------------------------- | -| **SVG** | `.svg` | Vetores diretos ou vetorização | Gráficos vetoriais, logos, designs | -| **DXF** | `.dxf` | Vetores diretos | Desenhos CAD, projetos técnicos | -| **PDF** | `.pdf` | Vetores diretos ou vetorização | Documentos com conteúdo vetorial | -| **LightBurn** | `.lbrn`, `.lbrn2` | Vetores diretos | Projetos LightBurn | -| **Ruida** | `.rd` | Vetores diretos | Arquivos de trabalho controlador Ruida | +| Formato | Extensão | Método de importação | Ideal para | +| --------------- | ----------------- | --------------------------------------------------- | -------------------------------------- | +| **SVG** | `.svg` | Vetores diretos ou vetorização | Gráficos vetoriais, logos, designs | +| **DXF** | `.dxf` | Vetores diretos | Desenhos CAD, projetos técnicos | +| **PDF** | `.pdf` | Vetores diretos ou vetorização | Documentos com conteúdo vetorial | +| **LightBurn** | `.lbrn`, `.lbrn2` | Vetores diretos | Projetos LightBurn | +| **Ruida** | `.rd` | Vetores diretos | Arquivos de trabalho controlador Ruida | +| **Illustrator** | `.ai` | Vetores diretos ou vetorização | Logotipos do Illustrator | +| **EPS** | `.eps`, `.ps` | Vetores diretos ou vetorização (requer Ghostscript) | Logotipos, arte para impressão | ### Formatos raster @@ -357,6 +359,18 @@ configurações de laser ajustáveis. --- +## Importação de Illustrator e EPS + +Arquivos do Illustrator salvos com compatibilidade com PDF, o padrão desde o Illustrator 9, são +importados exatamente como arquivos PDF, com a mesma seleção de camadas e as mesmas opções de +vetorização. + +Arquivos EPS, PostScript e arquivos antigos do Illustrator são primeiro convertidos em PDF com o +Ghostscript. Instale o Ghostscript se a caixa de diálogo de importação informar que ele está +faltando. Se não puder instalá-lo, exporte o desenho como PDF ou SVG no programa que o criou. + +--- + ## Importação de imagens raster (PNG, JPG, BMP, GIF, TIFF, WebP) As imagens raster são **vetorizadas** para criar caminhos vetoriais usando o diálogo de importação. diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/files/formats.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/files/formats.md index 4f15b9a78..f0cc60301 100644 --- a/website/i18n/uk/docusaurus-plugin-content-docs/current/files/formats.md +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/files/formats.md @@ -12,6 +12,8 @@ | **SVG** | Вектор | ✓ Прямий / Трасув. | ✓ Експорт об'єкт. | Основний формат дизайну | | **DXF** | Вектор | ✓ Прямий | ✓ Експорт об'єкт. | Обмін даними CAD | | **PDF** | Змішаний | ✓ Прямий / Трасув. | – | Документи з векторним вмістом | +| **Illustrator** | Вектор | ✓ Прямий / Трасув. | – | Логотипи з Illustrator | +| **EPS** | Вектор | ✓ Прямий / Трасув. | – | Логотипи, макети для друку | | **LightBurn** | Вектор | ✓ Прямий | – | Проєкти LightBurn | | **Ruida** | Вектор | ✓ Прямий | – | Файли завдань Ruida | | **PNG** | Растр | ✓ Трасування | – | Фотографії, зображення | @@ -219,6 +221,37 @@ LightBurn на Rayforge. --- +### Adobe Illustrator (.ai) + +**Розширення:** `.ai` **MIME-тип:** `application/illustrator` **Імпорт:** Прямі вектори або +рендеринг і трасування **Експорт:** Не підтримується + +Починаючи з версії 9, Illustrator типово зберігає файли з вбудованим PDF (параметр **Створити +PDF-сумісний файл**). Rayforge читає цей PDF, тож такі файли імпортуються так само, як PDF, разом із +шарами. + +Старіші файли Illustrator і файли, збережені без сумісності з PDF, є PostScript. Rayforge перетворює +їх за допомогою Ghostscript, якщо його встановлено. Інакше діалог імпорту пояснює, як зберегти файл +ще раз. + +--- + +### EPS і PostScript (.eps / .ps) + +**Розширення:** `.eps`, `.epsf`, `.ps` **MIME-тип:** `image/x-eps`, `application/postscript` +**Імпорт:** Прямі вектори або рендеринг і трасування, через Ghostscript **Експорт:** Не +підтримується + +Rayforge перетворює файли EPS і PostScript на PDF за допомогою Ghostscript, а потім імпортує їх як +PDF. Обмежувальна рамка EPS стає розміром сторінки, а текст перетворюється на контури. + +**Ghostscript** — необов'язкова залежність. У Linux встановіть пакет `ghostscript` вашого +дистрибутива. У Windows і macOS встановіть його з вебсайту Ghostscript. Без Ghostscript діалог +імпорту показує повідомлення замість попереднього перегляду. Проєкт зберігає перетворений PDF, тож +збережений проєкт відкривається й на комп'ютері без Ghostscript. + +--- + ## Растрові формати Усі растрові формати **імпортуються шляхом трасування** — автоматично конвертуються у векторні diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/files/importing.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/files/importing.md index 68b8eb6ef..91992f65f 100644 --- a/website/i18n/uk/docusaurus-plugin-content-docs/current/files/importing.md +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/files/importing.md @@ -7,13 +7,15 @@ Rayforge підтримує імпортування різних файлови ### Векторні формати -| Формат | Розширення | Метод імпорту | Найкраще для | -| ------------- | ----------------- | ---------------------------- | ----------------------------------- | -| **SVG** | `.svg` | Прямі вектори або трасування | Векторна графіка, логотипи, дизайни | -| **DXF** | `.dxf` | Прямі вектори | CAD-креслення, технічні проекти | -| **PDF** | `.pdf` | Прямі вектори або трасування | Документи з векторним вмістом | -| **LightBurn** | `.lbrn`, `.lbrn2` | Прямі вектори | Проєкти LightBurn | -| **Ruida** | `.rd` | Прямі вектори | Файли завдань контролера Ruida | +| Формат | Розширення | Метод імпорту | Найкраще для | +| --------------- | ----------------- | --------------------------------------------------- | ----------------------------------- | +| **SVG** | `.svg` | Прямі вектори або трасування | Векторна графіка, логотипи, дизайни | +| **DXF** | `.dxf` | Прямі вектори | CAD-креслення, технічні проекти | +| **PDF** | `.pdf` | Прямі вектори або трасування | Документи з векторним вмістом | +| **LightBurn** | `.lbrn`, `.lbrn2` | Прямі вектори | Проєкти LightBurn | +| **Ruida** | `.rd` | Прямі вектори | Файли завдань контролера Ruida | +| **Illustrator** | `.ai` | Прямі вектори або трасування | Логотипи з Illustrator | +| **EPS** | `.eps`, `.ps` | Прямі вектори або трасування (потрібен Ghostscript) | Логотипи, макети для друку | ### Растрові формати @@ -347,6 +349,17 @@ PDF, де текст не перетворено на контури — Rayforg --- +## Імпорт Illustrator і EPS + +Файли Illustrator, збережені із сумісністю з PDF (типово з Illustrator 9), імпортуються так само, як +файли PDF, з тим самим вибором шарів і тими самими параметрами трасування. + +Файли EPS, PostScript і старіші файли Illustrator спершу перетворюються на PDF за допомогою +Ghostscript. Встановіть Ghostscript, якщо діалог імпорту повідомляє, що його немає. Якщо встановити +його неможливо, експортуйте дизайн як PDF або SVG із програми, у якій його створено. + +--- + ## Імпортування растрових зображень (PNG, JPG, BMP, GIF, TIFF, WebP) Растрові зображення **трасуються** для створення векторних контурів за допомогою діалогового вікна diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/files/formats.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/files/formats.md index 6c2f0d703..3b5de2b40 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/files/formats.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/files/formats.md @@ -6,22 +6,24 @@ ### 快速参考 -| 格式 | 类型 | 导入 | 导出 | 推荐用途 | -| ----------------- | ---- | ----------- | ---------- | -------------------- | -| **SVG** | 矢量 | ✓ 直接/描摹 | ✓ 对象导出 | 主要设计格式 | -| **DXF** | 矢量 | ✓ 直接 | ✓ 对象导出 | CAD 数据交换 | -| **PDF** | 混合 | ✓ 直接/描摹 | – | 包含矢量内容的文档 | -| **LightBurn** | 矢量 | ✓ 直接 | – | LightBurn 项目文件 | -| **Ruida** | 矢量 | ✓ 直接 | – | Ruida 控制器作业文件 | -| **PNG** | 栅格 | ✓ 描摹 | – | 照片、图像 | -| **JPEG** | 栅格 | ✓ 描摹 | – | 照片 | -| **BMP** | 栅格 | ✓ 描摹 | – | 简单图形 | -| **GIF** | 栅格 | ✓ 描摹 | – | 简单图形、动画 | -| **TIFF** | 栅格 | ✓ 描摹 | – | 扫描件、印刷稿 | -| **WebP** | 栅格 | ✓ 描摹 | – | 网页图片 | -| **RFS** | 草图 | ✓ 直接 | ✓ 对象导出 | 参数化草图 | -| **G-code** | 控制 | – | ✓ 主要 | 机器输出 | -| **Rayforge 项目** | 项目 | ✓ | ✓ | 保存/加载项目 | +| 格式 | 类型 | 导入 | 导出 | 推荐用途 | +| ----------------- | ---- | ----------- | ---------- | ----------------------- | +| **SVG** | 矢量 | ✓ 直接/描摹 | ✓ 对象导出 | 主要设计格式 | +| **DXF** | 矢量 | ✓ 直接 | ✓ 对象导出 | CAD 数据交换 | +| **PDF** | 混合 | ✓ 直接/描摹 | – | 包含矢量内容的文档 | +| **Illustrator** | 矢量 | ✓ 直接/描摹 | – | 来自 Illustrator 的标志 | +| **EPS** | 矢量 | ✓ 直接/描摹 | – | 标志、印刷稿 | +| **LightBurn** | 矢量 | ✓ 直接 | – | LightBurn 项目文件 | +| **Ruida** | 矢量 | ✓ 直接 | – | Ruida 控制器作业文件 | +| **PNG** | 栅格 | ✓ 描摹 | – | 照片、图像 | +| **JPEG** | 栅格 | ✓ 描摹 | – | 照片 | +| **BMP** | 栅格 | ✓ 描摹 | – | 简单图形 | +| **GIF** | 栅格 | ✓ 描摹 | – | 简单图形、动画 | +| **TIFF** | 栅格 | ✓ 描摹 | – | 扫描件、印刷稿 | +| **WebP** | 栅格 | ✓ 描摹 | – | 网页图片 | +| **RFS** | 草图 | ✓ 直接 | ✓ 对象导出 | 参数化草图 | +| **G-code** | 控制 | – | ✓ 主要 | 机器输出 | +| **Rayforge 项目** | 项目 | ✓ | ✓ | 保存/加载项目 | --- @@ -210,6 +212,29 @@ PDF 文件可以包含实际的矢量路径,Rayforge 在可用时直接导入 --- +### Adobe Illustrator(.ai) + +**扩展名:** `.ai` **MIME 类型:** `application/illustrator` **导入:** 直接矢量或渲染后描摹 +**导出:** 不支持 + +从版本 9 起,Illustrator 默认在文件中嵌入一个 PDF(**创建 PDF 兼容文件**选项)。Rayforge 读取这个 PDF,因此这类文件的导入方式与 PDF 完全相同,包括图层。 + +较旧的 Illustrator 文件以及未启用 PDF 兼容保存的文件是 PostScript 格式。如果已安装 Ghostscript,Rayforge 会用它进行转换;否则导入对话框会说明如何重新保存文件。 + +--- + +### EPS 和 PostScript(.eps / .ps) + +**扩展名:** `.eps`、`.epsf`、`.ps` **MIME 类型:** `image/x-eps`、`application/postscript` +**导入:** 通过 Ghostscript 直接导入矢量或渲染后描摹 **导出:** 不支持 + +Rayforge 使用 Ghostscript 将 EPS 和 PostScript 文件转换为 PDF,然后像 PDF 一样导入。EPS 的边界框会成为页面尺寸,文字会转换为轮廓。 + +**Ghostscript** 是可选依赖。在 Linux 上,请安装发行版的 `ghostscript` +软件包;在 Windows 和 macOS 上,请从 Ghostscript 官网安装。没有 Ghostscript 时,导入对话框会显示提示信息而不是预览。项目会保存转换后的 PDF,因此已保存的项目在没有 Ghostscript 的电脑上也能打开。 + +--- + ## 栅格格式 所有栅格格式均**通过描摹导入** — 自动转换为矢量路径。 diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/files/importing.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/files/importing.md index fbcee9ac1..dde48a57d 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/files/importing.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/files/importing.md @@ -6,13 +6,15 @@ Rayforge 支持导入多种文件格式,包括矢量格式和光栅格式。 ### 矢量格式 -| 格式 | 扩展名 | 导入方式 | 适用场景 | -| ------------- | ----------------- | -------------- | -------------------- | -| **SVG** | `.svg` | 直接矢量或描摹 | 矢量图形、标志、设计 | -| **DXF** | `.dxf` | 直接矢量 | CAD 绘图、技术设计 | -| **PDF** | `.pdf` | 直接矢量或描摹 | 包含矢量内容的文档 | -| **LightBurn** | `.lbrn`, `.lbrn2` | 直接矢量 | LightBurn 项目文件 | -| **Ruida** | `.rd` | 直接矢量 | Ruida 控制器作业文件 | +| 格式 | 扩展名 | 导入方式 | 适用场景 | +| --------------- | ----------------- | ---------------------------------- | ----------------------- | +| **SVG** | `.svg` | 直接矢量或描摹 | 矢量图形、标志、设计 | +| **DXF** | `.dxf` | 直接矢量 | CAD 绘图、技术设计 | +| **PDF** | `.pdf` | 直接矢量或描摹 | 包含矢量内容的文档 | +| **LightBurn** | `.lbrn`, `.lbrn2` | 直接矢量 | LightBurn 项目文件 | +| **Ruida** | `.rd` | 直接矢量 | Ruida 控制器作业文件 | +| **Illustrator** | `.ai` | 直接矢量或描摹 | 来自 Illustrator 的标志 | +| **EPS** | `.eps`, `.ps` | 直接矢量或描摹(需要 Ghostscript) | 标志、印刷稿 | ### 光栅格式 @@ -324,6 +326,15 @@ LightBurn(.lbrn / --- +## Illustrator 和 EPS 导入 + +启用 PDF 兼容保存的 Illustrator 文件(Illustrator +9 起的默认设置)的导入方式与 PDF 文件完全相同,图层选择和描摹选项也相同。 + +EPS、PostScript 和较旧的 Illustrator 文件会先用 Ghostscript 转换为 PDF。如果导入对话框提示缺少 Ghostscript,请安装它。如果无法安装,请在制作该设计的软件中将其导出为 PDF 或 SVG。 + +--- + ## 光栅图像导入(PNG、JPG、BMP、GIF、TIFF、WebP) 光栅图像通过导入对话框被**描摹**为矢量路径。 From 1e316340603cc6714187977d9384208182734877 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Fri, 9 Oct 2026 13:09:20 +0200 Subject: [PATCH 009/104] feat(import): import HPGL plot files Add an HPGL importer for .plt/.hpgl files from cutting plotters, vinyl cutters and CAD programs. The parser handles IN/DF, SP, PU/PD, PA/PR (with free-format parameter lists), CI, AA/AR and the EA/ER/RA/RR rectangles, skips labels and PCL escape sequences, and converts plotter units (40 per mm) to millimetres. Each pen becomes a layer that can be selected in the import dialog; layer filtering and splitting reuse the DXF importer. Instructions that change the drawing but are not supported (for example SC, RO or LB) are reported as a single import warning. Also lists the new import formats in the README. Refs #517 --- CHANGELOG.md | 4 + README.md | 2 +- rayforge/image/__init__.py | 4 + rayforge/image/hpgl/__init__.py | 0 rayforge/image/hpgl/importer.py | 146 +++++++++++ rayforge/image/hpgl/parser.py | 233 ++++++++++++++++++ rayforge/image/hpgl/renderer.py | 10 + rayforge/locale/de/LC_MESSAGES/rayforge.po | 15 ++ rayforge/locale/en/LC_MESSAGES/rayforge.po | 15 ++ rayforge/locale/es/LC_MESSAGES/rayforge.po | 15 ++ rayforge/locale/fr/LC_MESSAGES/rayforge.po | 15 ++ rayforge/locale/hi/LC_MESSAGES/rayforge.po | 15 ++ rayforge/locale/pt/LC_MESSAGES/rayforge.po | 15 ++ rayforge/locale/rayforge.pot | 15 ++ rayforge/locale/uk/LC_MESSAGES/rayforge.po | 15 ++ rayforge/locale/zh_CN/LC_MESSAGES/rayforge.po | 15 ++ tests/image/hpgl/test_hpgl.py | 198 +++++++++++++++ website/docs/files/formats.md | 54 ++-- website/docs/files/importing.md | 1 + .../current/files/formats.md | 56 +++-- .../current/files/importing.md | 19 +- .../current/files/formats.md | 56 +++-- .../current/files/importing.md | 1 + .../current/files/formats.md | 56 +++-- .../current/files/importing.md | 19 +- .../current/files/formats.md | 19 ++ .../current/files/importing.md | 1 + .../current/files/formats.md | 56 +++-- .../current/files/importing.md | 19 +- .../current/files/formats.md | 54 ++-- .../current/files/importing.md | 1 + .../current/files/formats.md | 16 ++ .../current/files/importing.md | 1 + 33 files changed, 1025 insertions(+), 136 deletions(-) create mode 100644 rayforge/image/hpgl/__init__.py create mode 100644 rayforge/image/hpgl/importer.py create mode 100644 rayforge/image/hpgl/parser.py create mode 100644 rayforge/image/hpgl/renderer.py create mode 100644 tests/image/hpgl/test_hpgl.py diff --git a/CHANGELOG.md b/CHANGELOG.md index bc1b1cd5d..283c89fe6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Import: HPGL plot files (`.plt`, `.hpgl`) from cutting plotters and + CAD programs, with one selectable layer per pen. Pen moves, circles, + arcs and rectangles are supported; labels and other skipped + instructions are listed in the import dialog (#517) - Import: Adobe Illustrator (`.ai`) and EPS/PostScript files. AI files saved with PDF compatibility (the default since Illustrator 9) import like PDFs; EPS, PostScript and older AI files are converted to PDF diff --git a/README.md b/README.md index 62e0b942a..ed9942b33 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ We also have a [Discord](https://discord.gg/sTHNdTtpQJ). | **Multi-Layer Operations** | Assign different operations (e.g., engrave then cut) to layers in your design. | | **Stock Material System** | Document-level stock with geometry, thickness, and material assignment. | | **Undo/Redo** | Full undo/redo support across all document operations. | -| **Broad File Support** | Import from SVG, DXF, PDF, JPEG, PNG, BMP, and Ruida (`.rd`). Export to SVG and DXF. | +| **Broad File Support** | Import from SVG, DXF, PDF, Illustrator (`.ai`), EPS, HPGL (`.plt`), JPEG, PNG, BMP, GIF, TIFF, WebP, and Ruida (`.rd`). Export to SVG and DXF. | | **Project Files (.ryp)** | Compressed project format preserving all assets, layers, and configurations. | ### Operations & Toolpaths diff --git a/rayforge/image/__init__.py b/rayforge/image/__init__.py index a32f2f83b..aa7224975 100644 --- a/rayforge/image/__init__.py +++ b/rayforge/image/__init__.py @@ -20,6 +20,8 @@ from .dxf.exporter import GeometryDxfExporter from .dxf.importer import DxfImporter from .dxf.renderer import DXF_RENDERER +from .hpgl.importer import HpglImporter +from .hpgl.renderer import HPGL_RENDERER from .jpg.importer import JpgImporter from .jpg.renderer import JPG_RENDERER from .lightburn.importer import LightBurnImporter @@ -230,6 +232,7 @@ def import_file( BITMAP_RENDERER, BMP_RENDERER, DXF_RENDERER, + HPGL_RENDERER, LIGHTBURN_RENDERER, PROCEDURAL_RENDERER, JPG_RENDERER, @@ -258,6 +261,7 @@ def get_renderer_for_asset(asset_type: str) -> Renderer | None: "GeometryDxfExporter", "GeometrySvgExporter", "GifImporter", + "HpglImporter", "ImportManifest", "ImportPayload", "ImportResult", diff --git a/rayforge/image/hpgl/__init__.py b/rayforge/image/hpgl/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/rayforge/image/hpgl/importer.py b/rayforge/image/hpgl/importer.py new file mode 100644 index 000000000..ef6a76f68 --- /dev/null +++ b/rayforge/image/hpgl/importer.py @@ -0,0 +1,146 @@ +import logging +from gettext import gettext as _ +from pathlib import Path +from typing import ClassVar + +from raygeo.geo import Geometry + +from ...core.source_asset import SourceAsset +from ..base_importer import ImporterFeature +from ..dxf.importer import DxfImporter +from ..engine import NormalizationEngine +from ..structures import ( + ImportManifest, + LayerGeometry, + LayerInfo, + ParsingResult, +) +from .parser import HpglDrawing, parse_hpgl +from .renderer import HPGL_RENDERER + +logger = logging.getLogger(__name__) + + +def _layer_id(pen: int) -> str: + return f"pen-{pen}" + + +class HpglImporter(DxfImporter): + """ + Imports HP-GL plot files (.plt, .hpgl). Each pen becomes a layer that + can be selected in the import dialog. Layer filtering and splitting + are inherited from the DXF importer, which works on the same + per-layer, Y-up, millimetre geometry. + """ + + label = "HPGL plot files" + mime_types = ("application/vnd.hp-hpgl", "application/vnd.hp-HPGL") + extensions = (".plt", ".hpgl", ".hpg", ".hgl") + features: ClassVar[set[ImporterFeature]] = { + ImporterFeature.DIRECT_VECTOR, + ImporterFeature.LAYER_SELECTION, + } + + def __init__(self, data: bytes, source_file: Path | None = None): + super().__init__(data, source_file) + self._drawing: HpglDrawing | None = None + + def _get_drawing(self) -> HpglDrawing: + if self._drawing is None: + self._drawing = parse_hpgl(self.raw_data) + if self._drawing.unsupported: + names = ", ".join(sorted(self._drawing.unsupported)) + self.add_warning( + _( + "Some HPGL instructions are not supported and " + "were skipped: {names}" + ).format(names=names) + ) + if not self._drawing.geometries_by_pen: + self.add_error( + _("{filename} contains no HPGL drawing commands.").format( + filename=self.source_file.name + ) + ) + self._geometries_by_layer = { + _layer_id(pen): geo + for pen, geo in self._drawing.geometries_by_pen.items() + } + return self._drawing + + def _bounds(self) -> tuple[float, float, float, float] | None: + merged = self._merged_geometry() + if merged is None: + return None + min_x, min_y, max_x, max_y = merged.rect() + return min_x, min_y, max_x - min_x, max_y - min_y + + def scan(self) -> ImportManifest: + drawing = self._get_drawing() + bounds = self._bounds() + layers = [ + LayerInfo( + id=_layer_id(pen), + name=_("Pen {number}").format(number=pen), + feature_count=len(geo.split_into_contours()), + ) + for pen, geo in drawing.geometries_by_pen.items() + ] + return ImportManifest( + title=self.source_file.name, + layers=layers, + natural_size_mm=(bounds[2], bounds[3]) if bounds else None, + warnings=self._warnings, + errors=self._errors, + ) + + def parse(self) -> ParsingResult | None: + self._get_drawing() + bounds = self._bounds() + if bounds is None: + return None + + temp_result = ParsingResult( + document_bounds=bounds, + native_unit_to_mm=1.0, + is_y_down=False, + layers=[], + world_frame_of_reference=bounds, + background_world_transform=None, # type: ignore + ) + bg_item = NormalizationEngine.calculate_layout_item( + bounds, temp_result + ) + return ParsingResult( + document_bounds=bounds, + native_unit_to_mm=1.0, + is_y_down=False, + layers=[ + self._layer_geometry(layer_id, geo) + for layer_id, geo in self._geometries_by_layer.items() + if layer_id is not None + ], + world_frame_of_reference=bounds, + background_world_transform=bg_item.world_matrix, + ) + + @staticmethod + def _layer_geometry(layer_id: str, geo: Geometry) -> LayerGeometry: + min_x, min_y, max_x, max_y = geo.rect() + return LayerGeometry( + layer_id=layer_id, + name=layer_id, + content_bounds=(min_x, min_y, max_x - min_x, max_y - min_y), + ) + + def create_source_asset(self, parse_result: ParsingResult) -> SourceAsset: + _x, _y, w, h = parse_result.document_bounds + return SourceAsset( + source_file=self.source_file, + original_data=self.raw_data, + renderer=HPGL_RENDERER, + metadata={"is_vector": True}, + thumbnail_data=self._render_thumbnail(), + width_mm=w, + height_mm=h, + ) diff --git a/rayforge/image/hpgl/parser.py b/rayforge/image/hpgl/parser.py new file mode 100644 index 000000000..cd044c9d7 --- /dev/null +++ b/rayforge/image/hpgl/parser.py @@ -0,0 +1,233 @@ +""" +A parser for the common subset of HP-GL and HP-GL/2 plot files, as +written by cutting plotters, vinyl cutters and CAD programs. + +Coordinates are converted from plotter units to millimetres. The result +keeps HP-GL's own orientation, with Y pointing up. +""" + +import logging +import math +import re +from dataclasses import dataclass, field + +from raygeo.geo import Geometry + +logger = logging.getLogger(__name__) + +PLOTTER_UNITS_PER_MM = 40.0 +MAX_ARC_SWEEP_DEG = 90.0 + +_ESCAPE_RE = re.compile(rb"\x1b(?:%-?\d*[AB]|E|[^A-Za-z]*[A-Za-z@])") +_LABEL_RE = re.compile(rb"LB[^\x03]*\x03?", re.IGNORECASE) +_INSTRUCTION_RE = re.compile(r"([A-Za-z]{2})([^A-Za-z;]*)") +_NUMBER_RE = re.compile(r"[-+]?(?:\d+\.?\d*|\.\d+)") + +HARMLESS = frozenset( + { + "BP", + "CO", + "CT", + "DT", + "FS", + "LT", + "NP", + "NR", + "PC", + "PG", + "PS", + "PW", + "QL", + "SD", + "SS", + "TR", + "VS", + "WU", + } +) + + +@dataclass +class HpglDrawing: + """Geometry per pen, in millimetres, plus unsupported instructions.""" + + geometries_by_pen: dict[int, Geometry] = field(default_factory=dict) + unsupported: set[str] = field(default_factory=set) + + +@dataclass +class _State: + x: float = 0.0 + y: float = 0.0 + pen: int = 1 + pen_down: bool = False + absolute: bool = True + path_open: bool = False + + +class _Plotter: + """Executes HP-GL instructions and collects the drawn geometry.""" + + def __init__(self): + self.state = _State() + self.drawing = HpglDrawing() + + def _geometry(self) -> Geometry | None: + if self.state.pen <= 0: + return None + return self.drawing.geometries_by_pen.setdefault( + self.state.pen, Geometry() + ) + + def _start_path(self, geo: Geometry): + if not self.state.path_open: + geo.move_to(self.state.x, self.state.y) + self.state.path_open = True + + def _goto(self, x: float, y: float, draw: bool): + geo = self._geometry() if draw else None + if geo is not None: + self._start_path(geo) + geo.line_to(x, y) + else: + self.state.path_open = False + self.state.x, self.state.y = x, y + + def _target(self, dx: float, dy: float, relative: bool): + if relative: + return self.state.x + dx, self.state.y + dy + return dx, dy + + def plot(self, values: list[float], relative: bool | None = None): + if relative is None: + relative = not self.state.absolute + for i in range(0, len(values) - 1, 2): + x, y = self._target(values[i], values[i + 1], relative) + self._goto(x, y, self.state.pen_down) + + def arc(self, cx: float, cy: float, sweep_deg: float, draw: bool): + radius = math.hypot(self.state.x - cx, self.state.y - cy) + if radius <= 0.0 or sweep_deg == 0.0: + return + start = math.atan2(self.state.y - cy, self.state.x - cx) + steps = max(1, math.ceil(abs(sweep_deg) / MAX_ARC_SWEEP_DEG)) + step = math.radians(sweep_deg) / steps + geo = self._geometry() if draw else None + if geo is not None: + self._start_path(geo) + for k in range(1, steps + 1): + angle = start + step * k + x = cx + radius * math.cos(angle) + y = cy + radius * math.sin(angle) + if geo is not None: + geo.arc_to( + x, + y, + cx - self.state.x, + cy - self.state.y, + clockwise=sweep_deg < 0, + ) + self.state.x, self.state.y = x, y + if geo is None: + self.state.path_open = False + + def circle(self, radius: float): + cx, cy = self.state.x, self.state.y + geo = self._geometry() + if geo is None or radius <= 0.0: + return + geo.move_to(cx + radius, cy) + geo.arc_to(cx - radius, cy, -radius, 0.0, clockwise=False) + geo.arc_to(cx + radius, cy, radius, 0.0, clockwise=False) + self.state.path_open = False + + def rectangle(self, x: float, y: float): + x0, y0 = self.state.x, self.state.y + geo = self._geometry() + if geo is None: + return + geo.move_to(x0, y0) + for px, py in ((x, y0), (x, y), (x0, y), (x0, y0)): + geo.line_to(px, py) + self.state.path_open = False + + +def _mm(value: float) -> float: + return value / PLOTTER_UNITS_PER_MM + + +def _handle_pen(plotter: _Plotter, name: str, values: list[float]): + state = plotter.state + if name in ("PU", "PD"): + state.pen_down = name == "PD" + if not state.pen_down: + state.path_open = False + plotter.plot([_mm(v) for v in values]) + elif name in ("PA", "PR"): + state.absolute = name == "PA" + plotter.plot([_mm(v) for v in values]) + elif name == "SP": + state.pen = int(values[0]) if values else 0 + state.path_open = False + + +def _handle_shape(plotter: _Plotter, name: str, values: list[float]): + state = plotter.state + if name in ("AA", "AR") and len(values) >= 3: + cx, cy = plotter._target( + _mm(values[0]), _mm(values[1]), relative=name == "AR" + ) + plotter.arc(cx, cy, values[2], state.pen_down) + elif name == "CI" and values: + plotter.circle(_mm(values[0])) + elif name in ("EA", "ER", "RA", "RR") and len(values) >= 2: + x, y = plotter._target( + _mm(values[0]), _mm(values[1]), relative=name in ("ER", "RR") + ) + plotter.rectangle(x, y) + + +_PEN_INSTRUCTIONS = frozenset({"PU", "PD", "PA", "PR", "SP"}) +_SHAPE_INSTRUCTIONS = frozenset({"AA", "AR", "CI", "EA", "ER", "RA", "RR"}) + + +def _execute(plotter: _Plotter, name: str, values: list[float]): + if name in ("IN", "DF"): + plotter.state = _State(pen=plotter.state.pen) + elif name in _PEN_INSTRUCTIONS: + _handle_pen(plotter, name, values) + elif name in _SHAPE_INSTRUCTIONS: + _handle_shape(plotter, name, values) + elif name not in HARMLESS: + plotter.drawing.unsupported.add(name) + + +def parse_hpgl(data: bytes) -> HpglDrawing: + """ + Parses HP-GL data into geometry per pen, in millimetres. + + Supported are IN, DF, SP, PU, PD, PA, PR, CI, AA, AR and the edge + and fill rectangles EA, ER, RA and RR (drawn as outlines). Labels + (LB) are skipped. Other instructions that change the drawing, such + as scaling, are listed in ``unsupported``. + """ + cleaned = _ESCAPE_RE.sub(b"", data) + has_labels = _LABEL_RE.search(cleaned) is not None + cleaned = _LABEL_RE.sub(b";", cleaned) + text = cleaned.decode("ascii", errors="ignore") + + plotter = _Plotter() + for match in _INSTRUCTION_RE.finditer(text): + name = match.group(1).upper() + values = [float(v) for v in _NUMBER_RE.findall(match.group(2))] + _execute(plotter, name, values) + + drawing = plotter.drawing + if has_labels: + drawing.unsupported.add("LB") + drawing.geometries_by_pen = { + pen: geo + for pen, geo in sorted(drawing.geometries_by_pen.items()) + if not geo.is_empty() + } + return drawing diff --git a/rayforge/image/hpgl/renderer.py b/rayforge/image/hpgl/renderer.py new file mode 100644 index 000000000..26ee4d50f --- /dev/null +++ b/rayforge/image/hpgl/renderer.py @@ -0,0 +1,10 @@ +from ..dxf.renderer import DxfRenderer + + +class HpglRenderer(DxfRenderer): + """ + Renders HPGL workpieces from their vector boundaries, like DXF. + """ + + +HPGL_RENDERER = HpglRenderer() diff --git a/rayforge/locale/de/LC_MESSAGES/rayforge.po b/rayforge/locale/de/LC_MESSAGES/rayforge.po index 7228ea10f..f1e9c9098 100644 --- a/rayforge/locale/de/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/de/LC_MESSAGES/rayforge.po @@ -10488,3 +10488,18 @@ msgstr "Ghostscript konnte {filename} nicht umwandeln: {error}" #: rayforge/image/postscript/importer.py msgid "This Illustrator file was saved without PDF compatibility. Rayforge can only read it with Ghostscript, which was not found. Install Ghostscript, or save the file again in Illustrator with \"Create PDF Compatible File\" enabled." msgstr "Diese Illustrator-Datei wurde ohne PDF-Kompatibilität gespeichert. Rayforge kann sie nur mit Ghostscript lesen, das nicht gefunden wurde. Installiere Ghostscript oder speichere die Datei in Illustrator erneut mit aktivierter Option „PDF-kompatible Datei erstellen“." + +#: rayforge/image/hpgl/importer.py +#, python-brace-format +msgid "Some HPGL instructions are not supported and were skipped: {names}" +msgstr "Einige HPGL-Befehle werden nicht unterstützt und wurden übersprungen: {names}" + +#: rayforge/image/hpgl/importer.py +#, python-brace-format +msgid "{filename} contains no HPGL drawing commands." +msgstr "{filename} enthält keine HPGL-Zeichenbefehle." + +#: rayforge/image/hpgl/importer.py +#, python-brace-format +msgid "Pen {number}" +msgstr "Stift {number}" diff --git a/rayforge/locale/en/LC_MESSAGES/rayforge.po b/rayforge/locale/en/LC_MESSAGES/rayforge.po index 912f9db30..03df88562 100644 --- a/rayforge/locale/en/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/en/LC_MESSAGES/rayforge.po @@ -9846,3 +9846,18 @@ msgstr "" #: rayforge/image/postscript/importer.py msgid "This Illustrator file was saved without PDF compatibility. Rayforge can only read it with Ghostscript, which was not found. Install Ghostscript, or save the file again in Illustrator with \"Create PDF Compatible File\" enabled." msgstr "" + +#: rayforge/image/hpgl/importer.py +#, python-brace-format +msgid "Some HPGL instructions are not supported and were skipped: {names}" +msgstr "" + +#: rayforge/image/hpgl/importer.py +#, python-brace-format +msgid "{filename} contains no HPGL drawing commands." +msgstr "" + +#: rayforge/image/hpgl/importer.py +#, python-brace-format +msgid "Pen {number}" +msgstr "" diff --git a/rayforge/locale/es/LC_MESSAGES/rayforge.po b/rayforge/locale/es/LC_MESSAGES/rayforge.po index 47af754f4..295d8b2d1 100644 --- a/rayforge/locale/es/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/es/LC_MESSAGES/rayforge.po @@ -10462,3 +10462,18 @@ msgstr "Ghostscript no pudo convertir {filename}: {error}" #: rayforge/image/postscript/importer.py msgid "This Illustrator file was saved without PDF compatibility. Rayforge can only read it with Ghostscript, which was not found. Install Ghostscript, or save the file again in Illustrator with \"Create PDF Compatible File\" enabled." msgstr "Este archivo de Illustrator se guardó sin compatibilidad con PDF. Rayforge solo puede leerlo con Ghostscript, que no se encontró. Instala Ghostscript o vuelve a guardar el archivo en Illustrator con la opción «Crear archivo compatible con PDF» activada." + +#: rayforge/image/hpgl/importer.py +#, python-brace-format +msgid "Some HPGL instructions are not supported and were skipped: {names}" +msgstr "Algunas instrucciones HPGL no son compatibles y se omitieron: {names}" + +#: rayforge/image/hpgl/importer.py +#, python-brace-format +msgid "{filename} contains no HPGL drawing commands." +msgstr "{filename} no contiene comandos de dibujo HPGL." + +#: rayforge/image/hpgl/importer.py +#, python-brace-format +msgid "Pen {number}" +msgstr "Pluma {number}" diff --git a/rayforge/locale/fr/LC_MESSAGES/rayforge.po b/rayforge/locale/fr/LC_MESSAGES/rayforge.po index b80e61ddc..d310e1822 100644 --- a/rayforge/locale/fr/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/fr/LC_MESSAGES/rayforge.po @@ -10509,3 +10509,18 @@ msgstr "Ghostscript n'a pas pu convertir {filename} : {error}" #: rayforge/image/postscript/importer.py msgid "This Illustrator file was saved without PDF compatibility. Rayforge can only read it with Ghostscript, which was not found. Install Ghostscript, or save the file again in Illustrator with \"Create PDF Compatible File\" enabled." msgstr "Ce fichier Illustrator a été enregistré sans compatibilité PDF. Rayforge ne peut le lire qu'avec Ghostscript, qui est introuvable. Installez Ghostscript ou enregistrez à nouveau le fichier dans Illustrator avec l'option « Créer un fichier compatible PDF » activée." + +#: rayforge/image/hpgl/importer.py +#, python-brace-format +msgid "Some HPGL instructions are not supported and were skipped: {names}" +msgstr "Certaines instructions HPGL ne sont pas prises en charge et ont été ignorées : {names}" + +#: rayforge/image/hpgl/importer.py +#, python-brace-format +msgid "{filename} contains no HPGL drawing commands." +msgstr "{filename} ne contient aucune commande de tracé HPGL." + +#: rayforge/image/hpgl/importer.py +#, python-brace-format +msgid "Pen {number}" +msgstr "Plume {number}" diff --git a/rayforge/locale/hi/LC_MESSAGES/rayforge.po b/rayforge/locale/hi/LC_MESSAGES/rayforge.po index b86be0307..79fbf06e6 100644 --- a/rayforge/locale/hi/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/hi/LC_MESSAGES/rayforge.po @@ -10168,3 +10168,18 @@ msgstr "Ghostscript {filename} को परिवर्तित नहीं #: rayforge/image/postscript/importer.py msgid "This Illustrator file was saved without PDF compatibility. Rayforge can only read it with Ghostscript, which was not found. Install Ghostscript, or save the file again in Illustrator with \"Create PDF Compatible File\" enabled." msgstr "यह Illustrator फ़ाइल PDF संगतता के बिना सहेजी गई थी. Rayforge इसे केवल Ghostscript से पढ़ सकता है, जो नहीं मिला. Ghostscript इंस्टॉल करें, या Illustrator में \"Create PDF Compatible File\" चालू करके फ़ाइल फिर से सहेजें." + +#: rayforge/image/hpgl/importer.py +#, python-brace-format +msgid "Some HPGL instructions are not supported and were skipped: {names}" +msgstr "कुछ HPGL निर्देश समर्थित नहीं हैं और छोड़ दिए गए: {names}" + +#: rayforge/image/hpgl/importer.py +#, python-brace-format +msgid "{filename} contains no HPGL drawing commands." +msgstr "{filename} में कोई HPGL ड्रॉइंग कमांड नहीं है." + +#: rayforge/image/hpgl/importer.py +#, python-brace-format +msgid "Pen {number}" +msgstr "पेन {number}" diff --git a/rayforge/locale/pt/LC_MESSAGES/rayforge.po b/rayforge/locale/pt/LC_MESSAGES/rayforge.po index 95cba3547..a2e033f03 100644 --- a/rayforge/locale/pt/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/pt/LC_MESSAGES/rayforge.po @@ -10414,3 +10414,18 @@ msgstr "O Ghostscript não conseguiu converter {filename}: {error}" #: rayforge/image/postscript/importer.py msgid "This Illustrator file was saved without PDF compatibility. Rayforge can only read it with Ghostscript, which was not found. Install Ghostscript, or save the file again in Illustrator with \"Create PDF Compatible File\" enabled." msgstr "Este arquivo do Illustrator foi salvo sem compatibilidade com PDF. O Rayforge só consegue lê-lo com o Ghostscript, que não foi encontrado. Instale o Ghostscript ou salve o arquivo novamente no Illustrator com a opção \"Criar arquivo compatível com PDF\" ativada." + +#: rayforge/image/hpgl/importer.py +#, python-brace-format +msgid "Some HPGL instructions are not supported and were skipped: {names}" +msgstr "Algumas instruções HPGL não são suportadas e foram ignoradas: {names}" + +#: rayforge/image/hpgl/importer.py +#, python-brace-format +msgid "{filename} contains no HPGL drawing commands." +msgstr "{filename} não contém comandos de desenho HPGL." + +#: rayforge/image/hpgl/importer.py +#, python-brace-format +msgid "Pen {number}" +msgstr "Caneta {number}" diff --git a/rayforge/locale/rayforge.pot b/rayforge/locale/rayforge.pot index ea594e0c3..3b3c56a45 100644 --- a/rayforge/locale/rayforge.pot +++ b/rayforge/locale/rayforge.pot @@ -9847,3 +9847,18 @@ msgstr "" #: rayforge/image/postscript/importer.py msgid "This Illustrator file was saved without PDF compatibility. Rayforge can only read it with Ghostscript, which was not found. Install Ghostscript, or save the file again in Illustrator with \"Create PDF Compatible File\" enabled." msgstr "" + +#: rayforge/image/hpgl/importer.py +#, python-brace-format +msgid "Some HPGL instructions are not supported and were skipped: {names}" +msgstr "" + +#: rayforge/image/hpgl/importer.py +#, python-brace-format +msgid "{filename} contains no HPGL drawing commands." +msgstr "" + +#: rayforge/image/hpgl/importer.py +#, python-brace-format +msgid "Pen {number}" +msgstr "" diff --git a/rayforge/locale/uk/LC_MESSAGES/rayforge.po b/rayforge/locale/uk/LC_MESSAGES/rayforge.po index f58205a0c..cf3aa3340 100644 --- a/rayforge/locale/uk/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/uk/LC_MESSAGES/rayforge.po @@ -10374,3 +10374,18 @@ msgstr "Ghostscript не зміг перетворити {filename}: {error}" #: rayforge/image/postscript/importer.py msgid "This Illustrator file was saved without PDF compatibility. Rayforge can only read it with Ghostscript, which was not found. Install Ghostscript, or save the file again in Illustrator with \"Create PDF Compatible File\" enabled." msgstr "Цей файл Illustrator збережено без сумісності з PDF. Rayforge може прочитати його лише за допомогою Ghostscript, але його не знайдено. Встановіть Ghostscript або збережіть файл в Illustrator ще раз з увімкненим параметром «Створити PDF-сумісний файл»." + +#: rayforge/image/hpgl/importer.py +#, python-brace-format +msgid "Some HPGL instructions are not supported and were skipped: {names}" +msgstr "Деякі інструкції HPGL не підтримуються і були пропущені: {names}" + +#: rayforge/image/hpgl/importer.py +#, python-brace-format +msgid "{filename} contains no HPGL drawing commands." +msgstr "{filename} не містить команд малювання HPGL." + +#: rayforge/image/hpgl/importer.py +#, python-brace-format +msgid "Pen {number}" +msgstr "Перо {number}" diff --git a/rayforge/locale/zh_CN/LC_MESSAGES/rayforge.po b/rayforge/locale/zh_CN/LC_MESSAGES/rayforge.po index 866b96eab..f3c03fa97 100644 --- a/rayforge/locale/zh_CN/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/zh_CN/LC_MESSAGES/rayforge.po @@ -9975,3 +9975,18 @@ msgstr "Ghostscript 无法转换 {filename}:{error}" #: rayforge/image/postscript/importer.py msgid "This Illustrator file was saved without PDF compatibility. Rayforge can only read it with Ghostscript, which was not found. Install Ghostscript, or save the file again in Illustrator with \"Create PDF Compatible File\" enabled." msgstr "此 Illustrator 文件保存时未启用 PDF 兼容。Rayforge 只能借助 Ghostscript 读取它,但未找到 Ghostscript。请安装 Ghostscript,或在 Illustrator 中启用“创建 PDF 兼容文件”后重新保存该文件。" + +#: rayforge/image/hpgl/importer.py +#, python-brace-format +msgid "Some HPGL instructions are not supported and were skipped: {names}" +msgstr "部分 HPGL 指令不受支持,已跳过:{names}" + +#: rayforge/image/hpgl/importer.py +#, python-brace-format +msgid "{filename} contains no HPGL drawing commands." +msgstr "{filename} 不包含 HPGL 绘图命令。" + +#: rayforge/image/hpgl/importer.py +#, python-brace-format +msgid "Pen {number}" +msgstr "笔 {number}" diff --git a/tests/image/hpgl/test_hpgl.py b/tests/image/hpgl/test_hpgl.py new file mode 100644 index 000000000..8a00e5f18 --- /dev/null +++ b/tests/image/hpgl/test_hpgl.py @@ -0,0 +1,198 @@ +from pathlib import Path +from unittest.mock import Mock + +import pytest +from raygeo.geo import Matrix + +from rayforge.core.vectorization_spec import LayerImportMode, PassthroughSpec +from rayforge.core.workpiece import WorkPiece +from rayforge.image import import_file, importer_registry +from rayforge.image.base_importer import ImporterFeature +from rayforge.image.hpgl.importer import HpglImporter +from rayforge.image.hpgl.parser import parse_hpgl +from rayforge.image.hpgl.renderer import HPGL_RENDERER + +SQUARE = b"IN;SP1;PU0,0;PD400,0,400,400,0,400,0,0;PU;" + + +def _workpieces(result) -> list[WorkPiece]: + assert result is not None and result.payload is not None + found: list[WorkPiece] = [] + for item in result.payload.items: + if isinstance(item, WorkPiece): + found.append(item) + else: + found.extend(item.get_descendants(WorkPiece)) + return found + + +def _world_size(wp: WorkPiece) -> tuple[float, float]: + parent = Mock() + parent.get_world_transform.return_value = Matrix.identity() + wp.parent = parent + return wp.size + + +def _rect(geo): + return tuple(round(v, 4) for v in geo.rect()) + + +def _points(geo): + return [ + (round(c.end[0], 4), round(c.end[1], 4)) + for c in geo.iter_typed_commands() + ] + + +class TestParser: + def test_square_in_millimetres(self): + drawing = parse_hpgl(SQUARE) + assert list(drawing.geometries_by_pen) == [1] + geo = drawing.geometries_by_pen[1] + assert _rect(geo) == (0, 0, 10, 10) + assert geo.is_closed() + + def test_relative_and_absolute_moves(self): + drawing = parse_hpgl(b"IN;SP1;PA40,40;PD;PR400,0,0,400;PA40,40;") + assert _points(drawing.geometries_by_pen[1]) == [ + (1, 1), + (11, 1), + (11, 11), + (1, 1), + ] + + def test_pen_up_moves_do_not_draw(self): + drawing = parse_hpgl(b"IN;SP1;PU0,0,400,0;PD400,400;") + assert _points(drawing.geometries_by_pen[1]) == [(10, 0), (10, 10)] + + def test_pens_become_separate_geometries(self): + data = b"IN;SP1;PU0,0;PD400,0;SP2;PU0,400;PD400,400;SP1;PD800,400;" + drawing = parse_hpgl(data) + assert sorted(drawing.geometries_by_pen) == [1, 2] + assert _rect(drawing.geometries_by_pen[2]) == (0, 10, 10, 10) + assert _rect(drawing.geometries_by_pen[1]) == (0, 0, 20, 10) + + def test_pen_zero_draws_nothing(self): + drawing = parse_hpgl(b"IN;SP0;PU0,0;PD400,0;") + assert drawing.geometries_by_pen == {} + + def test_circle_around_current_position(self): + drawing = parse_hpgl(b"IN;SP1;PU400,400;CI200;") + geo = drawing.geometries_by_pen[1] + assert _rect(geo) == pytest.approx((5, 5, 15, 15), abs=1e-3) + assert geo.is_closed() + + def test_arc_absolute_counter_clockwise(self): + drawing = parse_hpgl(b"IN;SP1;PU400,0;PD;AA0,0,90;") + geo = drawing.geometries_by_pen[1] + assert _points(geo)[-1] == pytest.approx((0, 10), abs=1e-6) + assert _rect(geo) == pytest.approx((0, 0, 10, 10), abs=1e-3) + + def test_arc_relative_clockwise(self): + drawing = parse_hpgl(b"IN;SP1;PU0,400;PD;AR0,-400,-90;") + geo = drawing.geometries_by_pen[1] + assert _points(geo)[-1] == pytest.approx((10, 0), abs=1e-6) + + def test_full_circle_arc(self): + drawing = parse_hpgl(b"IN;SP1;PU400,0;PD;AA0,0,360;") + geo = drawing.geometries_by_pen[1] + assert _rect(geo) == pytest.approx((-10, -10, 10, 10), abs=1e-3) + + def test_edge_rectangles(self): + drawing = parse_hpgl(b"IN;SP1;PU0,0;EA400,200;PU800,0;ER400,200;") + geo = drawing.geometries_by_pen[1] + assert len(geo.split_into_contours()) == 2 + assert _rect(geo) == pytest.approx((0, 0, 30, 5)) + + def test_free_format_parameters(self): + drawing = parse_hpgl(b"IN SP1 PU 0 0 PD 400 0\nPD400 -400.5") + geo = drawing.geometries_by_pen[1] + assert _points(geo)[-1] == pytest.approx((10, -10.0125)) + + def test_pcl_escape_sequences_are_skipped(self): + drawing = parse_hpgl(b"\x1b%-1BIN;SP1;PU0,0;PD400,0;\x1b%0A") + assert _rect(drawing.geometries_by_pen[1]) == (0, 0, 10, 0) + + def test_labels_are_skipped_and_reported(self): + drawing = parse_hpgl(b"IN;SP1;PU0,0;LBHELLO;PD\x03PD400,0;") + assert _rect(drawing.geometries_by_pen[1]) == (0, 0, 10, 0) + assert "LB" in drawing.unsupported + + def test_scaling_is_reported(self): + drawing = parse_hpgl(b"IN;SC0,100,0,100;SP1;PD400,0;") + assert "SC" in drawing.unsupported + + def test_harmless_instructions_are_not_reported(self): + drawing = parse_hpgl(b"IN;VS10;FS2;SP1;PD400,0;") + assert drawing.unsupported == set() + + def test_garbage_has_no_geometry(self): + assert parse_hpgl(b"\x00\x01binary junk").geometries_by_pen == {} + + +class TestImporter: + def test_registration(self): + for ext in (".plt", ".hpgl", ".hpg", ".hgl"): + assert importer_registry.get_by_extension(ext) is HpglImporter + for mime in ("application/vnd.hp-hpgl", "application/vnd.hp-HPGL"): + assert importer_registry.get_by_mime_type(mime) is HpglImporter + assert ImporterFeature.DIRECT_VECTOR in HpglImporter.features + assert ImporterFeature.LAYER_SELECTION in HpglImporter.features + + def test_scan_lists_pens_as_layers(self): + data = b"IN;SP1;PU0,0;PD400,0;SP3;PU0,0;PD0,800;" + manifest = HpglImporter(data, Path("cut.plt")).scan() + assert manifest.errors == [] + assert manifest.natural_size_mm == pytest.approx((10, 20)) + assert [(layer.id, layer.name) for layer in manifest.layers] == [ + ("pen-1", "Pen 1"), + ("pen-3", "Pen 3"), + ] + + def test_import_creates_workpiece(self): + result = HpglImporter(SQUARE, Path("square.plt")).get_doc_items( + PassthroughSpec() + ) + items = _workpieces(result) + assert len(items) == 1 + assert _world_size(items[0]) == pytest.approx((10, 10)) + assert result is not None and result.payload is not None + source = result.payload.source + assert source.renderer is HPGL_RENDERER + assert source.original_data == SQUARE + assert source.metadata["_importer_class"] == "HpglImporter" + + def test_import_split_by_pen(self): + data = b"IN;SP1;PU0,0;PD400,0,400,400;SP2;PU800,0;PD1200,0,1200,400;" + spec = PassthroughSpec(layer_import_mode=LayerImportMode.NEW_LAYERS) + result = HpglImporter(data, Path("two.plt")).get_doc_items(spec) + assert len(_workpieces(result)) == 2 + + def test_import_only_selected_pen(self): + data = b"IN;SP1;PU0,0;PD400,0,400,400;SP2;PU800,0;PD1200,0,1200,400;" + spec = PassthroughSpec(active_layer_ids=["pen-2"]) + result = HpglImporter(data, Path("two.plt")).get_doc_items(spec) + workpieces = _workpieces(result) + assert len(workpieces) == 1 + assert _world_size(workpieces[0]) == pytest.approx((10, 10)) + + def test_unsupported_instructions_become_a_warning(self): + data = b"IN;SP1;PU0,0;PD400,0,400,400;LBX\x03;" + manifest = HpglImporter(data, Path("text.plt")).scan() + assert len(manifest.warnings) == 1 + assert "LB" in manifest.warnings[0] + + def test_file_without_drawing_reports_error(self): + importer = HpglImporter(b"not a plot file", Path("bad.plt")) + assert importer.scan().errors + result = HpglImporter(b"not a plot file", Path("bad.plt")) + doc = result.get_doc_items(PassthroughSpec()) + assert doc is not None + assert doc.payload is None or not doc.payload.items + + def test_import_file_by_extension(self, tmp_path): + path = tmp_path / "square.plt" + path.write_bytes(SQUARE) + payload = import_file(path) + assert payload is not None + assert payload.source.metadata["_importer_class"] == "HpglImporter" diff --git a/website/docs/files/formats.md b/website/docs/files/formats.md index b1f9d916f..62d6c2392 100644 --- a/website/docs/files/formats.md +++ b/website/docs/files/formats.md @@ -13,24 +13,25 @@ capabilities, limitations, and recommendations. ### Quick Reference -| Format | Type | Import | Export | Recommended Use | -| -------------------- | ------- | ---------------- | --------------- | ----------------------------- | -| **SVG** | Vector | ✓ Direct / Trace | ✓ Object export | Primary design format | -| **DXF** | Vector | ✓ Direct | ✓ Object export | CAD interchange | -| **PDF** | Mixed | ✓ Direct / Trace | – | Documents with vector content | -| **Illustrator** | Vector | ✓ Direct / Trace | – | Logos from Illustrator | -| **EPS** | Vector | ✓ Direct / Trace | – | Logos, print artwork | -| **LightBurn** | Vector | ✓ Direct | – | LightBurn project files | -| **Ruida** | Vector | ✓ Direct | – | Ruida controller job files | -| **PNG** | Raster | ✓ Trace | – | Photos, images | -| **JPEG** | Raster | ✓ Trace | – | Photos | -| **BMP** | Raster | ✓ Trace | – | Simple graphics | -| **GIF** | Raster | ✓ Trace | – | Simple graphics, animations | -| **TIFF** | Raster | ✓ Trace | – | Scans, print artwork | -| **WebP** | Raster | ✓ Trace | – | Web images | -| **RFS** | Sketch | ✓ Direct | ✓ Object export | Parametric sketches | -| **G-code** | Control | – | ✓ Primary | Machine output | -| **Rayforge Project** | Project | ✓ | ✓ | Save/load projects | +| Format | Type | Import | Export | Recommended Use | +| -------------------- | ------- | ---------------- | --------------- | ------------------------------ | +| **SVG** | Vector | ✓ Direct / Trace | ✓ Object export | Primary design format | +| **DXF** | Vector | ✓ Direct | ✓ Object export | CAD interchange | +| **PDF** | Mixed | ✓ Direct / Trace | – | Documents with vector content | +| **Illustrator** | Vector | ✓ Direct / Trace | – | Logos from Illustrator | +| **EPS** | Vector | ✓ Direct / Trace | – | Logos, print artwork | +| **LightBurn** | Vector | ✓ Direct | – | LightBurn project files | +| **Ruida** | Vector | ✓ Direct | – | Ruida controller job files | +| **HPGL** | Vector | ✓ Direct | – | Plotter and vinyl cutter files | +| **PNG** | Raster | ✓ Trace | – | Photos, images | +| **JPEG** | Raster | ✓ Trace | – | Photos | +| **BMP** | Raster | ✓ Trace | – | Simple graphics | +| **GIF** | Raster | ✓ Trace | – | Simple graphics, animations | +| **TIFF** | Raster | ✓ Trace | – | Scans, print artwork | +| **WebP** | Raster | ✓ Trace | – | Web images | +| **RFS** | Sketch | ✓ Direct | ✓ Object export | Parametric sketches | +| **G-code** | Control | – | ✓ Primary | Machine output | +| **Rayforge Project** | Project | ✓ | ✓ | Save/load projects | --- @@ -255,6 +256,23 @@ project also opens on a computer without Ghostscript. --- +### HPGL (.plt / .hpgl) + +**Extension:** `.plt`, `.hpgl`, `.hpg`, `.hgl` **MIME Type:** `application/vnd.hp-hpgl` **Import:** +Direct vector parsing **Export:** Not supported + +HPGL is the plotter language of cutting plotters, vinyl cutters and older CAD programs. Rayforge +reads the common drawing instructions: pen moves (PU, PD, PA, PR), circles and arcs (CI, AA, AR) and +rectangles (EA, ER, RA, RR). Each pen becomes a layer that you can pick in the import dialog. + +**Limitations:** + +- ✗ Text labels (LB) are skipped; convert text to paths before plotting +- ✗ Scaling (SC) and rotation (RO) are ignored; the import dialog lists any skipped instructions +- Coordinates use the standard 40 plotter units per millimetre + +--- + ## Raster Formats All raster formats are **imported by tracing** - converted to vector paths automatically. diff --git a/website/docs/files/importing.md b/website/docs/files/importing.md index 17922e2ee..9d2116c63 100644 --- a/website/docs/files/importing.md +++ b/website/docs/files/importing.md @@ -22,6 +22,7 @@ import files and optimize them for best results. | **Ruida** | `.rd` | Direct vectors | Ruida controller job files | | **Illustrator** | `.ai` | Direct vectors or trace | Logos from Illustrator | | **EPS** | `.eps`, `.ps` | Direct vectors or trace (needs Ghostscript) | Logos, print artwork | +| **HPGL** | `.plt`, `.hpgl` | Direct vectors | Plotter and vinyl cutter files | ### Raster Formats diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/files/formats.md b/website/i18n/de/docusaurus-plugin-content-docs/current/files/formats.md index ed317a86a..3ee90186c 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/files/formats.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/files/formats.md @@ -7,24 +7,25 @@ einschließlich Fähigkeiten, Einschränkungen und Empfehlungen. ### Schnellreferenz -| Format | Typ | Import | Export | Empfohlene Verwendung | -| -------------------- | --------- | ------------------ | --------------- | ------------------------------ | -| **SVG** | Vektor | ✓ Direkt / Tracing | ✓ Objekt-Export | Primäres Design-Format | -| **DXF** | Vektor | ✓ Direkt | ✓ Objekt-Export | CAD-Datenaustausch | -| **PDF** | Gemischt | ✓ Direkt / Tracing | – | Dokumente mit Vektorinhalt | -| **Illustrator** | Vektor | ✓ Direkt / Tracing | – | Logos aus Illustrator | -| **EPS** | Vektor | ✓ Direkt / Tracing | – | Logos, Druckvorlagen | -| **LightBurn** | Vektor | ✓ Direkt | – | LightBurn-Projekte | -| **Ruida** | Vektor | ✓ Direkt | – | Ruida-Aufgabendateien | -| **PNG** | Raster | ✓ Tracing | – | Fotos, Bilder | -| **JPEG** | Raster | ✓ Tracing | – | Fotos | -| **BMP** | Raster | ✓ Tracing | – | Einfache Grafiken | -| **GIF** | Raster | ✓ Tracing | – | Einfache Grafiken, Animationen | -| **TIFF** | Raster | ✓ Tracing | – | Scans, Druckvorlagen | -| **WebP** | Raster | ✓ Tracing | – | Webbilder | -| **RFS** | Skizze | ✓ Direkt | ✓ Objekt-Export | Parametrische Skizzen | -| **G-Code** | Steuerung | – | ✓ Primär | Maschinenausgabe | -| **Rayforge-Projekt** | Projekt | ✓ | ✓ | Projekte speichern/laden | +| Format | Typ | Import | Export | Empfohlene Verwendung | +| -------------------- | --------- | ------------------ | --------------- | ------------------------------------ | +| **SVG** | Vektor | ✓ Direkt / Tracing | ✓ Objekt-Export | Primäres Design-Format | +| **DXF** | Vektor | ✓ Direkt | ✓ Objekt-Export | CAD-Datenaustausch | +| **PDF** | Gemischt | ✓ Direkt / Tracing | – | Dokumente mit Vektorinhalt | +| **Illustrator** | Vektor | ✓ Direkt / Tracing | – | Logos aus Illustrator | +| **EPS** | Vektor | ✓ Direkt / Tracing | – | Logos, Druckvorlagen | +| **LightBurn** | Vektor | ✓ Direkt | – | LightBurn-Projekte | +| **Ruida** | Vektor | ✓ Direkt | – | Ruida-Aufgabendateien | +| **HPGL** | Vektor | ✓ Direkt | – | Plotter- und Schneideplotter-Dateien | +| **PNG** | Raster | ✓ Tracing | – | Fotos, Bilder | +| **JPEG** | Raster | ✓ Tracing | – | Fotos | +| **BMP** | Raster | ✓ Tracing | – | Einfache Grafiken | +| **GIF** | Raster | ✓ Tracing | – | Einfache Grafiken, Animationen | +| **TIFF** | Raster | ✓ Tracing | – | Scans, Druckvorlagen | +| **WebP** | Raster | ✓ Tracing | – | Webbilder | +| **RFS** | Skizze | ✓ Direkt | ✓ Objekt-Export | Parametrische Skizzen | +| **G-Code** | Steuerung | – | ✓ Primär | Maschinenausgabe | +| **Rayforge-Projekt** | Projekt | ✓ | ✓ | Projekte speichern/laden | --- @@ -252,6 +253,25 @@ umgewandelte PDF, daher lässt sich ein gespeichertes Projekt auch auf einem Rec --- +### HPGL (.plt / .hpgl) + +**Erweiterung:** `.plt`, `.hpgl`, `.hpg`, `.hgl` **MIME-Typ:** `application/vnd.hp-hpgl` **Import:** +Direkte Vektoranalyse **Export:** Nicht unterstützt + +HPGL ist die Plottersprache von Schneideplottern, Folienplottern und älteren CAD-Programmen. +Rayforge liest die üblichen Zeichenbefehle: Stiftbewegungen (PU, PD, PA, PR), Kreise und Bögen (CI, +AA, AR) sowie Rechtecke (EA, ER, RA, RR). Jeder Stift wird zu einer Ebene, die du im Importdialog +auswählen kannst. + +**Einschränkungen:** + +- ✗ Textbeschriftungen (LB) werden übersprungen; wandle Text vor dem Plotten in Pfade um +- ✗ Skalierung (SC) und Drehung (RO) werden ignoriert; der Importdialog listet übersprungene Befehle + auf +- Koordinaten verwenden die üblichen 40 Plottereinheiten pro Millimeter + +--- + ## Rasterformate Alle Rasterformate werden **durch Tracing importiert** - automatisch in Vektorpfade konvertiert. diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/files/importing.md b/website/i18n/de/docusaurus-plugin-content-docs/current/files/importing.md index 2671909ea..f9b57daa4 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/files/importing.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/files/importing.md @@ -7,15 +7,16 @@ Rasterformate. Diese Seite erklärt, wie du Dateien importierst und für beste E ### Vektorformate -| Format | Erweiterung | Importmethode | Am besten für | -| --------------- | ----------------- | ---------------------------------------------------- | ----------------------------------- | -| **SVG** | `.svg` | Direkte Vektoren oder Tracing | Vektorgrafiken, Logos, Designs | -| **DXF** | `.dxf` | Direkte Vektoren | CAD-Zeichnungen, technische Designs | -| **PDF** | `.pdf` | Direkte Vektoren oder Tracing | Dokumente mit Vektorinhalt | -| **LightBurn** | `.lbrn`, `.lbrn2` | Direkte Vektoren | LightBurn-Projekte | -| **Ruida** | `.rd` | Direkte Vektoren | Ruida-Controller-Auftragsdateien | -| **Illustrator** | `.ai` | Direkte Vektoren oder Tracing | Logos aus Illustrator | -| **EPS** | `.eps`, `.ps` | Direkte Vektoren oder Tracing (benötigt Ghostscript) | Logos, Druckvorlagen | +| Format | Erweiterung | Importmethode | Am besten für | +| --------------- | ----------------- | ---------------------------------------------------- | ------------------------------------ | +| **SVG** | `.svg` | Direkte Vektoren oder Tracing | Vektorgrafiken, Logos, Designs | +| **DXF** | `.dxf` | Direkte Vektoren | CAD-Zeichnungen, technische Designs | +| **PDF** | `.pdf` | Direkte Vektoren oder Tracing | Dokumente mit Vektorinhalt | +| **LightBurn** | `.lbrn`, `.lbrn2` | Direkte Vektoren | LightBurn-Projekte | +| **Ruida** | `.rd` | Direkte Vektoren | Ruida-Controller-Auftragsdateien | +| **Illustrator** | `.ai` | Direkte Vektoren oder Tracing | Logos aus Illustrator | +| **EPS** | `.eps`, `.ps` | Direkte Vektoren oder Tracing (benötigt Ghostscript) | Logos, Druckvorlagen | +| **HPGL** | `.plt`, `.hpgl` | Direkte Vektoren | Plotter- und Schneideplotter-Dateien | ### Rasterformate diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/files/formats.md b/website/i18n/es/docusaurus-plugin-content-docs/current/files/formats.md index 9a68f3737..86d83b70f 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/files/formats.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/files/formats.md @@ -7,24 +7,25 @@ Rayforge, incluyendo capacidades, limitaciones y recomendaciones. ### Referencia rápida -| Formato | Tipo | Importar | Exportar | Uso recomendado | -| --------------------- | -------- | ----------------- | --------------- | ------------------------------ | -| **SVG** | Vector | ✓ Directo / Traza | ✓ Exportar obj. | Formato de diseño principal | -| **DXF** | Vector | ✓ Directo | ✓ Exportar obj. | Intercambio CAD | -| **PDF** | Mixto | ✓ Directo / Traza | – | Documentos con contenido vec. | -| **Illustrator** | Vector | ✓ Directo / Traza | – | Logotipos de Illustrator | -| **EPS** | Vector | ✓ Directo / Traza | – | Logotipos, arte para impresión | -| **LightBurn** | Vector | ✓ Directo | – | Proyectos LightBurn | -| **Ruida** | Vector | ✓ Directo | – | Archivos de trabajo Ruida | -| **PNG** | Raster | ✓ Traza | – | Fotos, imágenes | -| **JPEG** | Raster | ✓ Traza | – | Fotos | -| **BMP** | Raster | ✓ Traza | – | Gráficos simples | -| **GIF** | Raster | ✓ Traza | – | Gráficos simples, animaciones | -| **TIFF** | Raster | ✓ Traza | – | Escaneos, arte para impresión | -| **WebP** | Raster | ✓ Traza | – | Imágenes web | -| **RFS** | Bosquejo | ✓ Directo | ✓ Exportar obj. | Bosquejos paramétricos | -| **G-code** | Control | – | ✓ Principal | Salida de máquina | -| **Proyecto Rayforge** | Proyecto | ✓ | ✓ | Guardar/cargar proyectos | +| Formato | Tipo | Importar | Exportar | Uso recomendado | +| --------------------- | -------- | ----------------- | --------------- | ------------------------------------ | +| **SVG** | Vector | ✓ Directo / Traza | ✓ Exportar obj. | Formato de diseño principal | +| **DXF** | Vector | ✓ Directo | ✓ Exportar obj. | Intercambio CAD | +| **PDF** | Mixto | ✓ Directo / Traza | – | Documentos con contenido vec. | +| **Illustrator** | Vector | ✓ Directo / Traza | – | Logotipos de Illustrator | +| **EPS** | Vector | ✓ Directo / Traza | – | Logotipos, arte para impresión | +| **LightBurn** | Vector | ✓ Directo | – | Proyectos LightBurn | +| **Ruida** | Vector | ✓ Directo | – | Archivos de trabajo Ruida | +| **HPGL** | Vector | ✓ Directo | – | Archivos de plóter y plóter de corte | +| **PNG** | Raster | ✓ Traza | – | Fotos, imágenes | +| **JPEG** | Raster | ✓ Traza | – | Fotos | +| **BMP** | Raster | ✓ Traza | – | Gráficos simples | +| **GIF** | Raster | ✓ Traza | – | Gráficos simples, animaciones | +| **TIFF** | Raster | ✓ Traza | – | Escaneos, arte para impresión | +| **WebP** | Raster | ✓ Traza | – | Imágenes web | +| **RFS** | Bosquejo | ✓ Directo | ✓ Exportar obj. | Bosquejos paramétricos | +| **G-code** | Control | – | ✓ Principal | Salida de máquina | +| **Proyecto Rayforge** | Proyecto | ✓ | ✓ | Guardar/cargar proyectos | --- @@ -253,6 +254,25 @@ convertido, así que un proyecto guardado también se abre en un equipo sin Ghos --- +### HPGL (.plt / .hpgl) + +**Extensión:** `.plt`, `.hpgl`, `.hpg`, `.hgl` **Tipo MIME:** `application/vnd.hp-hpgl` +**Importación:** Análisis vectorial directo **Exportación:** No soportado + +HPGL es el lenguaje de los plóteres de corte, los plóteres de vinilo y los programas CAD antiguos. +Rayforge lee las instrucciones de dibujo habituales: movimientos de pluma (PU, PD, PA, PR), círculos +y arcos (CI, AA, AR) y rectángulos (EA, ER, RA, RR). Cada pluma se convierte en una capa que puedes +elegir en el diálogo de importación. + +**Limitaciones:** + +- ✗ Las etiquetas de texto (LB) se omiten; convierte el texto en trazados antes de plotear +- ✗ El escalado (SC) y la rotación (RO) se ignoran; el diálogo de importación enumera las + instrucciones omitidas +- Las coordenadas usan las 40 unidades de plóter por milímetro habituales + +--- + ## Formatos ráster Todos los formatos ráster se **importan mediante trazado** — se convierten automáticamente a diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/files/importing.md b/website/i18n/es/docusaurus-plugin-content-docs/current/files/importing.md index c05ea7a02..7d8ed5725 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/files/importing.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/files/importing.md @@ -16,6 +16,7 @@ bits. Esta página explica cómo importar archivos y optimizarlos para obtener l | **Ruida** | `.rd` | Vectores directos | Archivos de trabajo controlador Ruida | | **Illustrator** | `.ai` | Vectores directos o trazo | Logotipos de Illustrator | | **EPS** | `.eps`, `.ps` | Vectores directos o trazo (requiere Ghostscript) | Logotipos, arte para impresión | +| **HPGL** | `.plt`, `.hpgl` | Vectores directos | Archivos de plóter y plóter de corte | ### Formatos de mapa de bits diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/files/formats.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/files/formats.md index fe201c72f..eba5f9e0b 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/files/formats.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/files/formats.md @@ -7,24 +7,25 @@ Rayforge, y compris les capacités, les limitations et les recommandations. ### Référence rapide -| Format | Type | Importation | Exportation | Utilisation recommandée | -| ------------------- | --------- | ---------------- | ------------- | ------------------------------------ | -| **SVG** | Vecteur | ✓ Direct / Trace | ✓ Export obj. | Format de conception principal | -| **DXF** | Vecteur | ✓ Direct | ✓ Export obj. | Échange CAO | -| **PDF** | Mixte | ✓ Direct / Trace | – | Documents avec contenu vectoriel | -| **Illustrator** | Vecteur | ✓ Direct / Trace | – | Logos issus d'Illustrator | -| **EPS** | Vecteur | ✓ Direct / Trace | – | Logos, fichiers d'impression | -| **LightBurn** | Vecteur | ✓ Direct | – | Projets LightBurn | -| **Ruida** | Vecteur | ✓ Direct | – | Fichiers de tâches Ruida | -| **PNG** | Matriciel | ✓ Trace | – | Photos, images | -| **JPEG** | Matriciel | ✓ Trace | – | Photos | -| **BMP** | Matriciel | ✓ Trace | – | Graphiques simples | -| **GIF** | Matriciel | ✓ Trace | – | Graphiques simples, animations | -| **TIFF** | Matriciel | ✓ Trace | – | Numérisations, fichiers d'impression | -| **WebP** | Matriciel | ✓ Trace | – | Images web | -| **RFS** | Croquis | ✓ Direct | ✓ Export obj. | Croquis paramétriques | -| **G-code** | Commande | – | ✓ Principal | Sortie machine | -| **Projet Rayforge** | Projet | ✓ | ✓ | Enregistrer/charger projets | +| Format | Type | Importation | Exportation | Utilisation recommandée | +| ------------------- | --------- | ---------------- | ------------- | -------------------------------------------- | +| **SVG** | Vecteur | ✓ Direct / Trace | ✓ Export obj. | Format de conception principal | +| **DXF** | Vecteur | ✓ Direct | ✓ Export obj. | Échange CAO | +| **PDF** | Mixte | ✓ Direct / Trace | – | Documents avec contenu vectoriel | +| **Illustrator** | Vecteur | ✓ Direct / Trace | – | Logos issus d'Illustrator | +| **EPS** | Vecteur | ✓ Direct / Trace | – | Logos, fichiers d'impression | +| **LightBurn** | Vecteur | ✓ Direct | – | Projets LightBurn | +| **Ruida** | Vecteur | ✓ Direct | – | Fichiers de tâches Ruida | +| **HPGL** | Vecteur | ✓ Direct | – | Fichiers de traceur et de plotter de découpe | +| **PNG** | Matriciel | ✓ Trace | – | Photos, images | +| **JPEG** | Matriciel | ✓ Trace | – | Photos | +| **BMP** | Matriciel | ✓ Trace | – | Graphiques simples | +| **GIF** | Matriciel | ✓ Trace | – | Graphiques simples, animations | +| **TIFF** | Matriciel | ✓ Trace | – | Numérisations, fichiers d'impression | +| **WebP** | Matriciel | ✓ Trace | – | Images web | +| **RFS** | Croquis | ✓ Direct | ✓ Export obj. | Croquis paramétriques | +| **G-code** | Commande | – | ✓ Principal | Sortie machine | +| **Projet Rayforge** | Projet | ✓ | ✓ | Enregistrer/charger projets | --- @@ -255,6 +256,25 @@ Ghostscript. --- +### HPGL (.plt / .hpgl) + +**Extension :** `.plt`, `.hpgl`, `.hpg`, `.hgl` **Type MIME :** `application/vnd.hp-hpgl` +**Importation :** Analyse vectorielle directe **Exportation :** Non pris en charge + +HPGL est le langage des traceurs de découpe, des plotters de découpe vinyle et des anciens logiciels +de CAO. Rayforge lit les instructions de tracé courantes : déplacements de plume (PU, PD, PA, PR), +cercles et arcs (CI, AA, AR) et rectangles (EA, ER, RA, RR). Chaque plume devient un calque que vous +pouvez choisir dans la boîte de dialogue d'importation. + +**Limitations :** + +- ✗ Les étiquettes de texte (LB) sont ignorées ; convertissez le texte en chemins avant le traçage +- ✗ La mise à l'échelle (SC) et la rotation (RO) sont ignorées ; la boîte de dialogue d'importation + liste les instructions ignorées +- Les coordonnées utilisent les 40 unités traceur par millimètre habituelles + +--- + ## Formats matriciels Tous les formats matriciels sont **importés par tracé** — convertis automatiquement en chemins diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/files/importing.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/files/importing.md index 33428236e..e86f94bee 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/files/importing.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/files/importing.md @@ -8,15 +8,16 @@ meilleurs résultats. ### Formats vectoriels -| Format | Extension | Méthode d'importation | Idéal pour | -| --------------- | ----------------- | --------------------------------------------------------- | ------------------------------------- | -| **SVG** | `.svg` | Vecteurs directs ou vectorisation | Graphiques vectoriels, logos, dessins | -| **DXF** | `.dxf` | Vecteurs directs | Dessins CAO, plans techniques | -| **PDF** | `.pdf` | Vecteurs directs ou vectorisation | Documents avec contenu vectoriel | -| **LightBurn** | `.lbrn`, `.lbrn2` | Vecteurs directs | Projets LightBurn | -| **Ruida** | `.rd` | Vecteurs directs | Fichiers de tâches contrôleur Ruida | -| **Illustrator** | `.ai` | Vecteurs directs ou vectorisation | Logos issus d'Illustrator | -| **EPS** | `.eps`, `.ps` | Vecteurs directs ou vectorisation (nécessite Ghostscript) | Logos, fichiers d'impression | +| Format | Extension | Méthode d'importation | Idéal pour | +| --------------- | ----------------- | --------------------------------------------------------- | -------------------------------------------- | +| **SVG** | `.svg` | Vecteurs directs ou vectorisation | Graphiques vectoriels, logos, dessins | +| **DXF** | `.dxf` | Vecteurs directs | Dessins CAO, plans techniques | +| **PDF** | `.pdf` | Vecteurs directs ou vectorisation | Documents avec contenu vectoriel | +| **LightBurn** | `.lbrn`, `.lbrn2` | Vecteurs directs | Projets LightBurn | +| **Ruida** | `.rd` | Vecteurs directs | Fichiers de tâches contrôleur Ruida | +| **Illustrator** | `.ai` | Vecteurs directs ou vectorisation | Logos issus d'Illustrator | +| **EPS** | `.eps`, `.ps` | Vecteurs directs ou vectorisation (nécessite Ghostscript) | Logos, fichiers d'impression | +| **HPGL** | `.plt`, `.hpgl` | Vecteurs directs | Fichiers de traceur et de plotter de découpe | ### Formats matriciels diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/files/formats.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/files/formats.md index c43061263..38f2067cd 100644 --- a/website/i18n/hi/docusaurus-plugin-content-docs/current/files/formats.md +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/files/formats.md @@ -22,6 +22,7 @@ description: | **EPS** | वेक्टर | ✓ Direct / Trace | – | लोगो, प्रिंट आर्टवर्क | | **LightBurn** | वेक्टर | ✓ Direct | – | LightBurn परियोजना फ़ाइलें | | **Ruida** | वेक्टर | ✓ Direct | – | Ruida नियंत्रक जॉब फ़ाइलें | +| **HPGL** | वेक्टर | ✓ Direct | – | प्लॉटर और विनाइल कटर फ़ाइलें | | **PNG** | रास्टर | ✓ Trace | – | फ़ोटो, छवियाँ | | **JPEG** | रास्टर | ✓ Trace | – | फ़ोटो | | **BMP** | रास्टर | ✓ Trace | – | सरल ग्राफ़िक्स | @@ -240,6 +241,24 @@ Rayforge EPS और PostScript फ़ाइलों को Ghostscript से P --- +### HPGL (.plt / .hpgl) + +**एक्सटेंशन:** `.plt`, `.hpgl`, `.hpg`, `.hgl` **MIME प्रकार:** `application/vnd.hp-hpgl` **आयात:** +सीधे वेक्टर पार्सिंग **निर्यात:** समर्थित नहीं + +HPGL कटिंग प्लॉटर, विनाइल कटर और पुराने CAD प्रोग्राम की प्लॉटर भाषा है. Rayforge सामान्य ड्रॉइंग +निर्देश पढ़ता है: पेन की चाल (PU, PD, PA, PR), वृत्त और चाप (CI, AA, AR) और आयत (EA, ER, RA, RR). हर +पेन एक लेयर बन जाता है, जिसे आप आयात डायलॉग में चुन सकते हैं. + +**सीमाएँ:** + +- ✗ टेक्स्ट लेबल (LB) छोड़ दिए जाते हैं; प्लॉट करने से पहले टेक्स्ट को पथ में बदलें +- ✗ स्केलिंग (SC) और रोटेशन (RO) अनदेखे किए जाते हैं; आयात डायलॉग छोड़े गए निर्देशों की सूची दिखाता + है +- निर्देशांक मानक 40 प्लॉटर इकाई प्रति मिलीमीटर का उपयोग करते हैं + +--- + ## रास्टर फ़ॉर्मेट सभी रास्टर फ़ॉर्मेट **ट्रेस करके आयात** होते हैं - स्वतः वेक्टर पाथ में बदले जाते हैं. diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/files/importing.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/files/importing.md index ca814d0ca..d213e05d6 100644 --- a/website/i18n/hi/docusaurus-plugin-content-docs/current/files/importing.md +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/files/importing.md @@ -22,6 +22,7 @@ Rayforge विभिन्न फ़ाइल फ़ॉर्मेट आय | **Ruida** | `.rd` | सीधे वेक्टर | Ruida नियंत्रक जॉब फ़ाइलें | | **Illustrator** | `.ai` | सीधे वेक्टर या ट्रेस | Illustrator से लोगो | | **EPS** | `.eps`, `.ps` | सीधे वेक्टर या ट्रेस (Ghostscript ज़रूरी) | लोगो, प्रिंट आर्टवर्क | +| **HPGL** | `.plt`, `.hpgl` | सीधे वेक्टर | प्लॉटर और विनाइल कटर फ़ाइलें | ### रास्टर फ़ॉर्मेट diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/files/formats.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/files/formats.md index dc4d214c4..5170c1f60 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/files/formats.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/files/formats.md @@ -7,24 +7,25 @@ Rayforge, incluindo capacidades, limitações e recomendações. ### Referência rápida -| Formato | Tipo | Importação | Exportação | Uso recomendado | -| -------------------- | -------- | ----------------- | --------------- | ----------------------------------- | -| **SVG** | Vetor | ✓ Direto / Traçar | ✓ Exportar obj. | Formato de design principal | -| **DXF** | Vetor | ✓ Direto | ✓ Exportar obj. | Intercâmbio CAD | -| **PDF** | Misto | ✓ Direto / Traçar | – | Documentos com conteúdo vet. | -| **Illustrator** | Vetor | ✓ Direto / Traçar | – | Logotipos do Illustrator | -| **EPS** | Vetor | ✓ Direto / Traçar | – | Logotipos, arte para impressão | -| **LightBurn** | Vetor | ✓ Direto | – | Projetos LightBurn | -| **Ruida** | Vetor | ✓ Direto | – | Arquivos de trabalho Ruida | -| **PNG** | Raster | ✓ Traçar | – | Fotos, imagens | -| **JPEG** | Raster | ✓ Traçar | – | Fotos | -| **BMP** | Raster | ✓ Traçar | – | Gráficos simples | -| **GIF** | Raster | ✓ Traçar | – | Gráficos simples, animações | -| **TIFF** | Raster | ✓ Traçar | – | Digitalizações, arte para impressão | -| **WebP** | Raster | ✓ Traçar | – | Imagens da web | -| **RFS** | Esboço | ✓ Direto | ✓ Exportar obj. | Esboços paramétricos | -| **G-code** | Controle | – | ✓ Principal | Saída da máquina | -| **Projeto Rayforge** | Projeto | ✓ | ✓ | Salvar/carregar projetos | +| Formato | Tipo | Importação | Exportação | Uso recomendado | +| -------------------- | -------- | ----------------- | --------------- | ---------------------------------------- | +| **SVG** | Vetor | ✓ Direto / Traçar | ✓ Exportar obj. | Formato de design principal | +| **DXF** | Vetor | ✓ Direto | ✓ Exportar obj. | Intercâmbio CAD | +| **PDF** | Misto | ✓ Direto / Traçar | – | Documentos com conteúdo vet. | +| **Illustrator** | Vetor | ✓ Direto / Traçar | – | Logotipos do Illustrator | +| **EPS** | Vetor | ✓ Direto / Traçar | – | Logotipos, arte para impressão | +| **LightBurn** | Vetor | ✓ Direto | – | Projetos LightBurn | +| **Ruida** | Vetor | ✓ Direto | – | Arquivos de trabalho Ruida | +| **HPGL** | Vetor | ✓ Direto | – | Arquivos de plotter e plotter de recorte | +| **PNG** | Raster | ✓ Traçar | – | Fotos, imagens | +| **JPEG** | Raster | ✓ Traçar | – | Fotos | +| **BMP** | Raster | ✓ Traçar | – | Gráficos simples | +| **GIF** | Raster | ✓ Traçar | – | Gráficos simples, animações | +| **TIFF** | Raster | ✓ Traçar | – | Digitalizações, arte para impressão | +| **WebP** | Raster | ✓ Traçar | – | Imagens da web | +| **RFS** | Esboço | ✓ Direto | ✓ Exportar obj. | Esboços paramétricos | +| **G-code** | Controle | – | ✓ Principal | Saída da máquina | +| **Projeto Rayforge** | Projeto | ✓ | ✓ | Salvar/carregar projetos | --- @@ -253,6 +254,25 @@ convertido, então um projeto salvo também abre em um computador sem Ghostscrip --- +### HPGL (.plt / .hpgl) + +**Extensão:** `.plt`, `.hpgl`, `.hpg`, `.hgl` **Tipo MIME:** `application/vnd.hp-hpgl` +**Importação:** Análise vetorial direta **Exportação:** Não suportado + +HPGL é a linguagem dos plotters de corte, plotters de recorte de vinil e programas CAD antigos. O +Rayforge lê as instruções de desenho comuns: movimentos da caneta (PU, PD, PA, PR), círculos e arcos +(CI, AA, AR) e retângulos (EA, ER, RA, RR). Cada caneta vira uma camada que você pode escolher na +caixa de diálogo de importação. + +**Limitações:** + +- ✗ Rótulos de texto (LB) são ignorados; converta o texto em caminhos antes de plotar +- ✗ Escala (SC) e rotação (RO) são ignoradas; a caixa de diálogo de importação lista as instruções + ignoradas +- As coordenadas usam as 40 unidades de plotter por milímetro padrão + +--- + ## Formatos raster Todos os formatos raster são **importados por traçado** — convertidos automaticamente em caminhos diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/files/importing.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/files/importing.md index 58b8213e3..ed7af3f52 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/files/importing.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/files/importing.md @@ -7,15 +7,16 @@ explica como importar arquivos e otimizá-los para obter os melhores resultados. ### Formatos vetoriais -| Formato | Extensão | Método de importação | Ideal para | -| --------------- | ----------------- | --------------------------------------------------- | -------------------------------------- | -| **SVG** | `.svg` | Vetores diretos ou vetorização | Gráficos vetoriais, logos, designs | -| **DXF** | `.dxf` | Vetores diretos | Desenhos CAD, projetos técnicos | -| **PDF** | `.pdf` | Vetores diretos ou vetorização | Documentos com conteúdo vetorial | -| **LightBurn** | `.lbrn`, `.lbrn2` | Vetores diretos | Projetos LightBurn | -| **Ruida** | `.rd` | Vetores diretos | Arquivos de trabalho controlador Ruida | -| **Illustrator** | `.ai` | Vetores diretos ou vetorização | Logotipos do Illustrator | -| **EPS** | `.eps`, `.ps` | Vetores diretos ou vetorização (requer Ghostscript) | Logotipos, arte para impressão | +| Formato | Extensão | Método de importação | Ideal para | +| --------------- | ----------------- | --------------------------------------------------- | ---------------------------------------- | +| **SVG** | `.svg` | Vetores diretos ou vetorização | Gráficos vetoriais, logos, designs | +| **DXF** | `.dxf` | Vetores diretos | Desenhos CAD, projetos técnicos | +| **PDF** | `.pdf` | Vetores diretos ou vetorização | Documentos com conteúdo vetorial | +| **LightBurn** | `.lbrn`, `.lbrn2` | Vetores diretos | Projetos LightBurn | +| **Ruida** | `.rd` | Vetores diretos | Arquivos de trabalho controlador Ruida | +| **Illustrator** | `.ai` | Vetores diretos ou vetorização | Logotipos do Illustrator | +| **EPS** | `.eps`, `.ps` | Vetores diretos ou vetorização (requer Ghostscript) | Logotipos, arte para impressão | +| **HPGL** | `.plt`, `.hpgl` | Vetores diretos | Arquivos de plotter e plotter de recorte | ### Formatos raster diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/files/formats.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/files/formats.md index f0cc60301..c4327cfa8 100644 --- a/website/i18n/uk/docusaurus-plugin-content-docs/current/files/formats.md +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/files/formats.md @@ -7,24 +7,25 @@ ### Швидка довідка -| Формат | Тип | Імпорт | Експорт | Рекомендоване використання | -| ------------------- | --------- | ------------------ | ----------------- | ----------------------------- | -| **SVG** | Вектор | ✓ Прямий / Трасув. | ✓ Експорт об'єкт. | Основний формат дизайну | -| **DXF** | Вектор | ✓ Прямий | ✓ Експорт об'єкт. | Обмін даними CAD | -| **PDF** | Змішаний | ✓ Прямий / Трасув. | – | Документи з векторним вмістом | -| **Illustrator** | Вектор | ✓ Прямий / Трасув. | – | Логотипи з Illustrator | -| **EPS** | Вектор | ✓ Прямий / Трасув. | – | Логотипи, макети для друку | -| **LightBurn** | Вектор | ✓ Прямий | – | Проєкти LightBurn | -| **Ruida** | Вектор | ✓ Прямий | – | Файли завдань Ruida | -| **PNG** | Растр | ✓ Трасування | – | Фотографії, зображення | -| **JPEG** | Растр | ✓ Трасування | – | Фотографії | -| **BMP** | Растр | ✓ Трасування | – | Проста графіка | -| **GIF** | Растр | ✓ Трасування | – | Проста графіка, анімації | -| **TIFF** | Растр | ✓ Трасування | – | Скани, макети для друку | -| **WebP** | Растр | ✓ Трасування | – | Вебзображення | -| **RFS** | Ескіз | ✓ Прямий | ✓ Експорт об'єкт. | Параметричні ескізи | -| **G-code** | Керування | – | ✓ Основний | Вивід для машини | -| **Проєкт Rayforge** | Проєкт | ✓ | ✓ | Збер./завант. проєктів | +| Формат | Тип | Імпорт | Експорт | Рекомендоване використання | +| ------------------- | --------- | ------------------ | ----------------- | ---------------------------------- | +| **SVG** | Вектор | ✓ Прямий / Трасув. | ✓ Експорт об'єкт. | Основний формат дизайну | +| **DXF** | Вектор | ✓ Прямий | ✓ Експорт об'єкт. | Обмін даними CAD | +| **PDF** | Змішаний | ✓ Прямий / Трасув. | – | Документи з векторним вмістом | +| **Illustrator** | Вектор | ✓ Прямий / Трасув. | – | Логотипи з Illustrator | +| **EPS** | Вектор | ✓ Прямий / Трасув. | – | Логотипи, макети для друку | +| **LightBurn** | Вектор | ✓ Прямий | – | Проєкти LightBurn | +| **Ruida** | Вектор | ✓ Прямий | – | Файли завдань Ruida | +| **HPGL** | Вектор | ✓ Прямий | – | Файли плотерів і вінілових катерів | +| **PNG** | Растр | ✓ Трасування | – | Фотографії, зображення | +| **JPEG** | Растр | ✓ Трасування | – | Фотографії | +| **BMP** | Растр | ✓ Трасування | – | Проста графіка | +| **GIF** | Растр | ✓ Трасування | – | Проста графіка, анімації | +| **TIFF** | Растр | ✓ Трасування | – | Скани, макети для друку | +| **WebP** | Растр | ✓ Трасування | – | Вебзображення | +| **RFS** | Ескіз | ✓ Прямий | ✓ Експорт об'єкт. | Параметричні ескізи | +| **G-code** | Керування | – | ✓ Основний | Вивід для машини | +| **Проєкт Rayforge** | Проєкт | ✓ | ✓ | Збер./завант. проєктів | --- @@ -252,6 +253,23 @@ PDF. Обмежувальна рамка EPS стає розміром стор --- +### HPGL (.plt / .hpgl) + +**Розширення:** `.plt`, `.hpgl`, `.hpg`, `.hgl` **MIME-тип:** `application/vnd.hp-hpgl` **Імпорт:** +Прямий векторний аналіз **Експорт:** Не підтримується + +HPGL — мова ріжучих плотерів, вінілових катерів і старіших CAD-програм. Rayforge читає поширені +команди малювання: рухи пера (PU, PD, PA, PR), кола й дуги (CI, AA, AR) та прямокутники (EA, ER, RA, +RR). Кожне перо стає шаром, який можна вибрати в діалозі імпорту. + +**Обмеження:** + +- ✗ Текстові написи (LB) пропускаються; перед плотуванням перетворіть текст на контури +- ✗ Масштабування (SC) і поворот (RO) ігноруються; діалог імпорту показує пропущені інструкції +- Координати використовують стандартні 40 одиниць плотера на міліметр + +--- + ## Растрові формати Усі растрові формати **імпортуються шляхом трасування** — автоматично конвертуються у векторні diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/files/importing.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/files/importing.md index 91992f65f..6546f7f19 100644 --- a/website/i18n/uk/docusaurus-plugin-content-docs/current/files/importing.md +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/files/importing.md @@ -16,6 +16,7 @@ Rayforge підтримує імпортування різних файлови | **Ruida** | `.rd` | Прямі вектори | Файли завдань контролера Ruida | | **Illustrator** | `.ai` | Прямі вектори або трасування | Логотипи з Illustrator | | **EPS** | `.eps`, `.ps` | Прямі вектори або трасування (потрібен Ghostscript) | Логотипи, макети для друку | +| **HPGL** | `.plt`, `.hpgl` | Прямі вектори | Файли плотерів і вінілових катерів | ### Растрові формати diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/files/formats.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/files/formats.md index 3b5de2b40..c6c1a7d70 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/files/formats.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/files/formats.md @@ -15,6 +15,7 @@ | **EPS** | 矢量 | ✓ 直接/描摹 | – | 标志、印刷稿 | | **LightBurn** | 矢量 | ✓ 直接 | – | LightBurn 项目文件 | | **Ruida** | 矢量 | ✓ 直接 | – | Ruida 控制器作业文件 | +| **HPGL** | 矢量 | ✓ 直接 | – | 绘图仪和刻字机文件 | | **PNG** | 栅格 | ✓ 描摹 | – | 照片、图像 | | **JPEG** | 栅格 | ✓ 描摹 | – | 照片 | | **BMP** | 栅格 | ✓ 描摹 | – | 简单图形 | @@ -235,6 +236,21 @@ Rayforge 使用 Ghostscript 将 EPS 和 PostScript 文件转换为 PDF,然后 --- +### HPGL(.plt / .hpgl) + +**扩展名:** `.plt`、`.hpgl`、`.hpg`、`.hgl` **MIME 类型:** `application/vnd.hp-hpgl` **导入:** +直接矢量解析 **导出:** 不支持 + +HPGL 是切割绘图仪、刻字机和较旧 CAD 软件使用的绘图语言。Rayforge 可读取常用的绘图指令:笔的移动(PU、PD、PA、PR)、圆和圆弧(CI、AA、AR)以及矩形(EA、ER、RA、RR)。每支笔都会成为一个图层,您可以在导入对话框中选择。 + +**限制:** + +- ✗ 文字标签(LB)会被跳过;请在绘图前将文字转换为路径 +- ✗ 缩放(SC)和旋转(RO)会被忽略;导入对话框会列出被跳过的指令 +- 坐标采用标准的每毫米 40 个绘图仪单位 + +--- + ## 栅格格式 所有栅格格式均**通过描摹导入** — 自动转换为矢量路径。 diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/files/importing.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/files/importing.md index dde48a57d..1438188b2 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/files/importing.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/files/importing.md @@ -15,6 +15,7 @@ Rayforge 支持导入多种文件格式,包括矢量格式和光栅格式。 | **Ruida** | `.rd` | 直接矢量 | Ruida 控制器作业文件 | | **Illustrator** | `.ai` | 直接矢量或描摹 | 来自 Illustrator 的标志 | | **EPS** | `.eps`, `.ps` | 直接矢量或描摹(需要 Ghostscript) | 标志、印刷稿 | +| **HPGL** | `.plt`, `.hpgl` | 直接矢量 | 绘图仪和刻字机文件 | ### 光栅格式 From 318cc9f1bad08102cb0cb5cc00912c722fbcd82b Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Fri, 9 Oct 2026 13:09:25 +0200 Subject: [PATCH 010/104] fix(import): don't suggest trace mode for files that cannot be read When the scan of a file reports errors, for example an EPS file while Ghostscript is missing, tracing fails as well. The import dialog no longer shows the "Switch to Trace Mode" hint next to the error banner in that case. Refs #517 --- rayforge/ui_gtk/doceditor/import_dialog.py | 6 ++- .../test_import_dialog_scan_errors.py | 41 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 tests/ui_gtk/doceditor/test_import_dialog_scan_errors.py diff --git a/rayforge/ui_gtk/doceditor/import_dialog.py b/rayforge/ui_gtk/doceditor/import_dialog.py index 5183336c9..1467535ff 100644 --- a/rayforge/ui_gtk/doceditor/import_dialog.py +++ b/rayforge/ui_gtk/doceditor/import_dialog.py @@ -642,9 +642,13 @@ def _update_ui_with_preview(self, result: PreviewResult | None): or not self._preview_result.payload.items ) can_trace = ImporterFeature.BITMAP_TRACING in self.features + unreadable = bool(self._manifest and self._manifest.errors) # Only show warning if switching to trace mode is possible self.warning_banner.set_revealed( - is_direct_vector and failed_generation and can_trace + is_direct_vector + and failed_generation + and can_trace + and not unreadable ) def _draw_checkerboard_background( diff --git a/tests/ui_gtk/doceditor/test_import_dialog_scan_errors.py b/tests/ui_gtk/doceditor/test_import_dialog_scan_errors.py new file mode 100644 index 000000000..f99ab0ed5 --- /dev/null +++ b/tests/ui_gtk/doceditor/test_import_dialog_scan_errors.py @@ -0,0 +1,41 @@ +# flake8: noqa: E402 +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") + +import pytest +from gi.repository import Gtk + +from rayforge.doceditor.editor import DocEditor +from rayforge.image.base_importer import ImporterFeature +from rayforge.image.postscript import ghostscript +from rayforge.ui_gtk.doceditor.import_dialog import ImportDialog + +EPS = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 10 10\nshowpage\n" +FEATURES = {ImporterFeature.DIRECT_VECTOR, ImporterFeature.BITMAP_TRACING} + + +@pytest.fixture +def editor(ui_context_initializer, ui_task_mgr): + editor = DocEditor( + task_manager=ui_task_mgr, context=ui_context_initializer + ) + yield editor + editor.cleanup() + + +@pytest.mark.ui +def test_scan_error_hides_switch_to_trace_hint(editor, tmp_path, monkeypatch): + monkeypatch.setattr(ghostscript, "find_ghostscript", lambda: None) + ghostscript.clear_cache() + path = tmp_path / "logo.eps" + path.write_bytes(EPS) + + dialog = ImportDialog(Gtk.Window(), editor, path, "image/x-eps", FEATURES) + dialog._update_ui_with_preview(None) + + assert dialog.error_banner.get_revealed() + assert "Ghostscript" in dialog.error_banner.get_title() + assert not dialog.warning_banner.get_revealed() + dialog.destroy() From f666a7727eb3c3df14948e0640e54e3c22a4a0d3 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Fri, 9 Oct 2026 13:23:06 +0200 Subject: [PATCH 011/104] feat(import): remember the original file of converted sources EPS, PostScript and PostScript-only .ai sources embed the PDF that Ghostscript produced, so original_data no longer equals the file on disk. The importer now also records the SHA-256 and size of the original file in the source asset's metadata. SourceAsset gains source_file_sha256, source_file_size and matches_source_file(). They fall back to original_data when no fingerprint is stored, which covers every unconverted source and projects saved before this change, so existing projects load and compare as before. Code that checks whether the file on disk changed since the import should use matches_source_file() instead of hashing original_data. Refs #517 --- rayforge/core/source_asset.py | 44 +++++++++++++++++++ rayforge/image/postscript/importer.py | 14 ++++-- tests/core/test_source_asset.py | 39 ++++++++++++++++ .../postscript/test_postscript_import.py | 30 +++++++++++++ 4 files changed, 124 insertions(+), 3 deletions(-) diff --git a/rayforge/core/source_asset.py b/rayforge/core/source_asset.py index 1160374f9..897daff1f 100644 --- a/rayforge/core/source_asset.py +++ b/rayforge/core/source_asset.py @@ -1,6 +1,7 @@ from __future__ import annotations import base64 +import hashlib import logging import uuid from collections import OrderedDict @@ -16,6 +17,9 @@ logger = logging.getLogger(__name__) +SOURCE_FILE_SHA256_KEY = "source_file_sha256" +SOURCE_FILE_SIZE_KEY = "source_file_size" + if TYPE_CHECKING: from ..image.base_renderer import Renderer @@ -82,6 +86,46 @@ def cache_base_image( def clear_base_image_cache(self) -> None: self._base_image_cache.clear() + def set_source_file_fingerprint(self, data: bytes) -> None: + """ + Records the SHA-256 and size of the file as it was on disk. + + Importers that convert a file before embedding it (for example EPS + to PDF) call this with the original bytes, so the file on disk can + still be recognised although ``original_data`` holds the + converted data. + """ + self.metadata[SOURCE_FILE_SHA256_KEY] = hashlib.sha256( + data + ).hexdigest() + self.metadata[SOURCE_FILE_SIZE_KEY] = len(data) + + @property + def source_file_sha256(self) -> str: + """ + The SHA-256 (hex) of the imported file as it was on disk. Falls + back to the embedded data, which is the file itself for every + source that was not converted, and for older projects. + """ + digest = self.metadata.get(SOURCE_FILE_SHA256_KEY) + if isinstance(digest, str): + return digest + return hashlib.sha256(self.original_data).hexdigest() + + @property + def source_file_size(self) -> int: + """The size in bytes of the imported file as it was on disk.""" + size = self.metadata.get(SOURCE_FILE_SIZE_KEY) + if isinstance(size, int): + return size + return len(self.original_data) + + def matches_source_file(self, data: bytes) -> bool: + """True if the bytes are those of the file this asset came from.""" + if len(data) != self.source_file_size: + return False + return hashlib.sha256(data).hexdigest() == self.source_file_sha256 + @property def uid(self) -> str: """The unique identifier of the asset instance.""" diff --git a/rayforge/image/postscript/importer.py b/rayforge/image/postscript/importer.py index bf7831cb5..5a7b82609 100644 --- a/rayforge/image/postscript/importer.py +++ b/rayforge/image/postscript/importer.py @@ -24,12 +24,14 @@ class _PostScriptImporter(PdfImporter): Imports files that are either PDF streams or PostScript. PDF data is handed to the PDF importer unchanged; PostScript is first converted to PDF with Ghostscript. The source asset keeps the PDF, so projects - render and re-import without Ghostscript. + render and re-import without Ghostscript, plus the SHA-256 and size + of the original file, so that file can still be recognised on disk. """ def __init__(self, data: bytes, source_file: Path | None = None): super().__init__(data, source_file) self._pdf_ready: bool | None = None + self._converted_from: bytes | None = None def _missing_ghostscript_message(self) -> str: return _( @@ -44,7 +46,9 @@ def _ensure_pdf(self) -> bool: self._pdf_ready = True return True try: - self.raw_data = ghostscript.convert_to_pdf(self.raw_data) + original = self.raw_data + self.raw_data = ghostscript.convert_to_pdf(original) + self._converted_from = original self._pdf_ready = True except ghostscript.GhostscriptNotFound: self.add_error(self._missing_ghostscript_message()) @@ -85,7 +89,11 @@ def get_doc_items( ) -> ImportResult | None: if not self._ensure_pdf(): return self._failed_result() - return super().get_doc_items(vectorization_spec) + result = super().get_doc_items(vectorization_spec) + source = result.payload.source if result and result.payload else None + if source is not None and self._converted_from is not None: + source.set_source_file_fingerprint(self._converted_from) + return result def get_doc_items_for_reimport( self, diff --git a/tests/core/test_source_asset.py b/tests/core/test_source_asset.py index 4641f0a43..86fbe446a 100644 --- a/tests/core/test_source_asset.py +++ b/tests/core/test_source_asset.py @@ -1,4 +1,5 @@ import base64 +import hashlib from pathlib import Path from unittest.mock import patch @@ -109,3 +110,41 @@ def test_roundtrip_serialization(self, basic_asset: SourceAsset): assert restored_asset.base_render_data == basic_asset.base_render_data assert isinstance(restored_asset.renderer, SvgRenderer) assert restored_asset.metadata == basic_asset.metadata + + +class TestSourceFileFingerprint: + """The fingerprint identifies the file on disk an asset came from.""" + + def test_defaults_to_the_embedded_data(self, basic_asset): + data = basic_asset.original_data + assert ( + basic_asset.source_file_sha256 == hashlib.sha256(data).hexdigest() + ) + assert basic_asset.source_file_size == len(data) + assert basic_asset.matches_source_file(data) + assert not basic_asset.matches_source_file(data + b" ") + + def test_converted_source_remembers_the_original_file(self, basic_asset): + original = b"%!PS-Adobe-3.0 EPSF-3.0\n...original eps bytes..." + basic_asset.set_source_file_fingerprint(original) + + assert ( + basic_asset.source_file_sha256 + == hashlib.sha256(original).hexdigest() + ) + assert basic_asset.source_file_size == len(original) + assert basic_asset.matches_source_file(original) + assert not basic_asset.matches_source_file(basic_asset.original_data) + + def test_fingerprint_survives_serialization(self, basic_asset): + original = b"original file bytes" + basic_asset.set_source_file_fingerprint(original) + restored = SourceAsset.from_dict(basic_asset.to_dict()) + assert restored.matches_source_file(original) + assert restored.source_file_size == len(original) + + def test_projects_without_fingerprint_still_load(self, basic_asset): + data = basic_asset.to_dict() + data["metadata"] = {} + restored = SourceAsset.from_dict(data) + assert restored.matches_source_file(basic_asset.original_data) diff --git a/tests/image/postscript/test_postscript_import.py b/tests/image/postscript/test_postscript_import.py index 4f7d50aca..b8417f17a 100644 --- a/tests/image/postscript/test_postscript_import.py +++ b/tests/image/postscript/test_postscript_import.py @@ -191,6 +191,36 @@ def test_vectors_are_imported(self): assert source.renderer is PDF_RENDERER assert source.metadata["_importer_class"] == "EpsImporter" + @needs_gs + def test_source_remembers_the_original_eps_file(self): + result = EpsImporter(EPS_DATA, Path("logo.eps")).get_doc_items( + PassthroughSpec() + ) + source = _source(result) + assert source.original_data != EPS_DATA + assert source.matches_source_file(EPS_DATA) + assert source.source_file_size == len(EPS_DATA) + assert not source.matches_source_file(source.original_data) + + @needs_gs + def test_reimport_keeps_the_original_fingerprint(self): + source = _source( + EpsImporter(EPS_DATA, Path("logo.eps")).get_doc_items( + PassthroughSpec() + ) + ) + importer = EpsImporter(source.original_data, Path("logo.eps")) + again = importer.get_doc_items_for_reimport(source, PassthroughSpec()) + assert again is not None + assert source.matches_source_file(EPS_DATA) + + def test_pdf_compatible_ai_fingerprint_is_the_file(self, no_ghostscript): + data = _pdf_compatible_ai() + source = _source( + AiImporter(data, Path("logo.ai")).get_doc_items(PassthroughSpec()) + ) + assert source.matches_source_file(data) + @needs_gs def test_reimport_does_not_need_ghostscript(self, monkeypatch): result = EpsImporter(EPS_DATA, Path("logo.eps")).get_doc_items( From 73858e4e00488b0b260065b0714c32f064ff05cd Mon Sep 17 00:00:00 2001 From: Steve Isaacs Date: Fri, 9 Oct 2026 06:54:30 -0700 Subject: [PATCH 012/104] feature: Route Z home to the auto-focus routine --- rayforge/machine/driver/ruidarpa/rpa_adapter.py | 2 +- .../machine/driver/ruidarpa/rpa_direct_driver.py | 11 +++++++++++ tests/machine/driver/ruidarpa/test_adapter.py | 14 ++++++++------ .../machine/driver/ruidarpa/test_direct_driver.py | 1 + 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/rayforge/machine/driver/ruidarpa/rpa_adapter.py b/rayforge/machine/driver/ruidarpa/rpa_adapter.py index d7183d7fa..73a033ea1 100644 --- a/rayforge/machine/driver/ruidarpa/rpa_adapter.py +++ b/rayforge/machine/driver/ruidarpa/rpa_adapter.py @@ -1250,7 +1250,7 @@ async def home(self, axes: Axis | None = None) -> None: if axes is None or (axes & (Axis.X | Axis.Y)): await loop.run_in_executor(None, self._backend.home) if axes is not None and (axes & Axis.Z): - await loop.run_in_executor(None, self._backend.home_z) + await loop.run_in_executor(None, self._backend.focus_z) async def move_to( self, diff --git a/rayforge/machine/driver/ruidarpa/rpa_direct_driver.py b/rayforge/machine/driver/ruidarpa/rpa_direct_driver.py index ca6b65ec3..5f65b16a1 100644 --- a/rayforge/machine/driver/ruidarpa/rpa_direct_driver.py +++ b/rayforge/machine/driver/ruidarpa/rpa_direct_driver.py @@ -239,6 +239,17 @@ def home_z(self) -> None: """ self._require_connected().home_z() + def focus_z(self) -> None: + """Run the Z auto-focus routine. + + Probes until the focus sensor triggers, then moves to the + configured focus distance and sets Z to it (same as the panel's + Focus key). The wrapped RdDriver auto-sends the generated lines + when connected; the returned lines are deliberately discarded so + each command is sent exactly once. + """ + self._require_connected().focus_z() + def jog_xy_to(self, x: float, y: float) -> None: """Jog the XY axes to an absolute position in mm. diff --git a/tests/machine/driver/ruidarpa/test_adapter.py b/tests/machine/driver/ruidarpa/test_adapter.py index 14bb3a24b..2f8b0c51b 100644 --- a/tests/machine/driver/ruidarpa/test_adapter.py +++ b/tests/machine/driver/ruidarpa/test_adapter.py @@ -795,11 +795,12 @@ async def test_home_uses_client_home_without_run(self, adapter_pair): @pytest.mark.parametrize( "adapter_pair", [RPC_MODE], ids=["rpc"], indirect=True ) - async def test_home_z_uses_client_home_z(self, adapter_pair): - """home(Axis.Z) must call client.home_z only.""" + async def test_home_z_uses_client_focus_z(self, adapter_pair): + """home(Axis.Z) must call client.focus_z only.""" adapter, client = adapter_pair await adapter.home(Axis.Z) - client.home_z.assert_called_once() + client.focus_z.assert_called_once() + client.home_z.assert_not_called() client.home.assert_not_called() client.run.assert_not_called() @@ -940,11 +941,12 @@ async def test_home_calls_backend_home_without_run(self, adapter_pair): @pytest.mark.parametrize( "adapter_pair", [DIRECT_MODE], ids=["direct"], indirect=True ) - async def test_home_z_calls_backend_home_z(self, adapter_pair): - """home(Axis.Z) must call backend.home_z only.""" + async def test_home_z_calls_backend_focus_z(self, adapter_pair): + """home(Axis.Z) must call backend.focus_z only.""" adapter, backend = adapter_pair await adapter.home(Axis.Z) - backend.home_z.assert_called_once() + backend.focus_z.assert_called_once() + backend.home_z.assert_not_called() backend.home.assert_not_called() backend.run.assert_not_called() diff --git a/tests/machine/driver/ruidarpa/test_direct_driver.py b/tests/machine/driver/ruidarpa/test_direct_driver.py index f22fbcd9b..460ecc0de 100644 --- a/tests/machine/driver/ruidarpa/test_direct_driver.py +++ b/tests/machine/driver/ruidarpa/test_direct_driver.py @@ -18,6 +18,7 @@ _LIVE_METHODS = [ ("home", ()), ("home_z", ()), + ("focus_z", ()), ("jog_xy_to", (10.0, 20.0)), ("jog_xy_rel", (5.0, 5.0)), ("jog_x_rel", (5.0,)), From 5b846f08b581345d4bfcf09ad72aa6e1e4b610c3 Mon Sep 17 00:00:00 2001 From: Steve Isaacs Date: Fri, 9 Oct 2026 09:17:00 -0700 Subject: [PATCH 013/104] fix: Guard empty-tuple value unwrap (lint + latent IndexError) --- rayforge/machine/driver/ruidarpa/rpa_adapter.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/rayforge/machine/driver/ruidarpa/rpa_adapter.py b/rayforge/machine/driver/ruidarpa/rpa_adapter.py index 73a033ea1..c09d95462 100644 --- a/rayforge/machine/driver/ruidarpa/rpa_adapter.py +++ b/rayforge/machine/driver/ruidarpa/rpa_adapter.py @@ -130,7 +130,10 @@ def _unwrap_field(value: object) -> str: forward compatibility. """ if isinstance(value, (list, tuple)): - return str(value[1]) if len(value) > 1 else str(value[0]) + if len(value) > 1: + return str(value[1]) + if value: + return str(value[0]) return str(value) From 9f4a4162b7bb4926ec94e26a929b4aab3dcca6f8 Mon Sep 17 00:00:00 2001 From: Thijs Kaper Date: Fri, 9 Oct 2026 21:57:35 +0200 Subject: [PATCH 014/104] feat: optional camera view outside the workarea --- rayforge/camera/controller.py | 162 ++++++++ rayforge/camera/models/camera.py | 100 +++++ rayforge/camera/outside_view.py | 321 ++++++++++++++++ rayforge/locale/de/LC_MESSAGES/rayforge.po | 32 ++ rayforge/locale/en/LC_MESSAGES/rayforge.po | 32 ++ rayforge/locale/es/LC_MESSAGES/rayforge.po | 32 ++ rayforge/locale/fr/LC_MESSAGES/rayforge.po | 32 ++ rayforge/locale/hi/LC_MESSAGES/rayforge.po | 32 ++ rayforge/locale/pt/LC_MESSAGES/rayforge.po | 32 ++ rayforge/locale/rayforge.pot | 32 ++ rayforge/locale/uk/LC_MESSAGES/rayforge.po | 32 ++ rayforge/locale/zh_CN/LC_MESSAGES/rayforge.po | 32 ++ .../ui_gtk/camera/camera_preferences_page.py | 7 + rayforge/ui_gtk/camera/outside_view_widget.py | 131 +++++++ .../ui_gtk/canvas2d/elements/camera_image.py | 259 ++++++++++--- rayforge/ui_gtk/canvas2d/surface.py | 4 +- tests/camera/test_camera.py | 81 ++++ tests/camera/test_controller.py | 348 ++++++++++++++++++ .../ui_gtk/camera/test_outside_view_widget.py | 97 +++++ tests/ui_gtk/canvas2d/test_camera_image.py | 164 +++++++++ 20 files changed, 1918 insertions(+), 44 deletions(-) create mode 100644 rayforge/camera/outside_view.py create mode 100644 rayforge/ui_gtk/camera/outside_view_widget.py create mode 100644 tests/ui_gtk/camera/test_outside_view_widget.py create mode 100644 tests/ui_gtk/canvas2d/test_camera_image.py diff --git a/rayforge/camera/controller.py b/rayforge/camera/controller.py index ee2aa0bca..b5dffc08b 100644 --- a/rayforge/camera/controller.py +++ b/rayforge/camera/controller.py @@ -10,6 +10,18 @@ from ..image.util.srgb import resize_linear_nd from ..shared.util.glib import idle_add from .models.camera import Camera +from .outside_view import ( + LensModel, + OutsideView, + expand_area, + frame_coverage_extent, + lens_coverage_extent, + lens_remap_maps, + native_output_size, + remap_transparent, + to_opaque_bgra, + warp_transparent, +) from .source import ( create_camera_source, list_local_device_ids, @@ -70,6 +82,9 @@ def __init__(self, config: Camera): self._active_source: CameraSource | None = None self._last_source_key = self._source_key() self._disposed: bool = False + # Cached outside view coverage and remap tables, which only + # change with calibration, alignment, and display geometry. + self._outside_cache: dict[str, tuple] = {} # Stream lifecycle: _lifecycle_lock serializes start/stop so that # at most one capture thread can ever own the device at a time @@ -119,6 +134,8 @@ def _on_config_changed(self, sender): """Reacts to changes in the data model.""" if self._disposed: return + if not self.config.outside_view_enabled: + self._outside_cache.clear() self._settings_dirty = True with self._frame_lock: self._accumulator = None # Reset smoothing if settings change @@ -346,6 +363,66 @@ def get_work_surface_image( logger.warning("No image data available.") return None + return self._work_surface_from_image(image, output_size, physical_area) + + def get_work_surface_images( + self, + output_size: tuple[int, int], + physical_area: tuple[Pos, Pos], + outside_margin_mm: float, + max_dimension: int, + ) -> tuple[np.ndarray | None, OutsideView | None]: + """ + Get the workspace image and the surrounding outside view from a + single frame. + + The workspace image is identical to what + :meth:`get_work_surface_image` returns for the same arguments. The + outside view covers ``physical_area`` expanded by the effective + margin on all four sides, which is ``outside_margin_mm`` clamped to + the area the camera actually sees. It is rendered at the camera's + pixel density, capped to ``max_dimension``, independent of + ``output_size``. + + With lens correction, the outside view is sampled from the raw + frame captured together with the processed one, since lens + correction crops the edges of the processed frame. + + Returns: + A tuple (workspace image, outside view). The outside view is + None without alignment, without coverage beyond the workspace, + or on failure; the workspace image is None on failure. + """ + with self._frame_lock: + image = self._image_data + raw = self._raw_image_data + + if image is None: + logger.warning("No image data available.") + return None, None + + workspace = self._work_surface_from_image( + image, output_size, physical_area + ) + try: + outside = self._outside_view_from_image( + image, + raw, + physical_area, + outside_margin_mm, + max_dimension, + ) + except (cv2.error, ValueError, np.linalg.LinAlgError) as e: + logger.error(f"Failed to render outside camera view: {e}") + outside = None + return workspace, outside + + def _work_surface_from_image( + self, + image: np.ndarray, + output_size: tuple[int, int], + physical_area: tuple[Pos, Pos], + ) -> np.ndarray | None: if self.config.image_to_world is not None: return self._transform_with_homography( image, output_size, physical_area @@ -418,6 +495,91 @@ def _transform_with_homography( logger.error(f"Failed to apply perspective warp: {e}") return None + def _outside_view_from_image( + self, + image: np.ndarray, + raw: np.ndarray | None, + physical_area: tuple[Pos, Pos], + margin_mm: float, + max_dimension: int, + ) -> OutsideView | None: + if self.config.image_to_world is None or margin_mm <= 0: + return None + H = self._compute_homography(image.shape[0]) + lens = self._lens_model(image) + if lens is not None and raw is not None: + return self._outside_view_from_raw( + raw, + H, + lens, + physical_area, + margin_mm, + max_dimension, + ) + + margin = min( + margin_mm, + frame_coverage_extent(H, image.shape, physical_area), + ) + if margin <= 0: + return None + area = expand_area(physical_area, margin) + size = native_output_size(H, physical_area, margin, max_dimension) + warped = warp_transparent(to_opaque_bgra(image), H, size, area) + return OutsideView(image=warped, area=area, margin_mm=margin) + + def _outside_view_from_raw( + self, + raw: np.ndarray, + H: np.ndarray, + lens: LensModel, + physical_area: tuple[Pos, Pos], + margin_mm: float, + max_dimension: int, + ) -> OutsideView | None: + """ + Render the outside view from the raw frame, so that content lens + correction pushed out of the corrected frame is still shown. + """ + base_key = (H.tobytes(), lens.key(), raw.shape[:2], physical_area) + + extent_key = (base_key, margin_mm) + cached = self._outside_cache.get("extent") + if cached is not None and cached[0] == extent_key: + margin = cached[1] + else: + margin = lens_coverage_extent( + H, lens, raw.shape, physical_area, margin_mm + ) + self._outside_cache["extent"] = (extent_key, margin) + if margin <= 0: + return None + + area = expand_area(physical_area, margin) + size = native_output_size(H, physical_area, margin, max_dimension) + maps_key = (base_key, area, size) + cached = self._outside_cache.get("maps") + if cached is not None and cached[0] == maps_key: + maps = cached[1] + else: + maps = lens_remap_maps(H, lens, size, area) + self._outside_cache["maps"] = (maps_key, maps) + + remapped = remap_transparent(to_opaque_bgra(raw), maps) + return OutsideView(image=remapped, area=area, margin_mm=margin) + + def _lens_model(self, image: np.ndarray) -> LensModel | None: + """The lens correction applied to frames of this image's size.""" + cam_mat, dist = self._get_effective_calibration( + image.shape[0], image.shape[1] + ) + if cam_mat is None or dist is None: + return None + return LensModel( + camera_matrix=np.asarray(cam_mat, dtype=np.float64), + distortion=np.asarray(dist, dtype=np.float64), + ) + def _apply_settings(self, source) -> None: """Applies the current settings to the VideoCapture object.""" try: diff --git a/rayforge/camera/models/camera.py b/rayforge/camera/models/camera.py index 10f961868..8abb0b3d4 100644 --- a/rayforge/camera/models/camera.py +++ b/rayforge/camera/models/camera.py @@ -1,5 +1,6 @@ import json import logging +import math import uuid from collections.abc import Sequence from datetime import datetime, timezone @@ -15,6 +16,8 @@ Pos = tuple[float, float] PointList = Sequence[Pos] +OUTSIDE_VIEW_MAX_MARGIN_MM = 100.0 + def _as_utc(dt: datetime | None) -> datetime | None: """Interpret a possibly naive datetime as UTC. @@ -60,6 +63,12 @@ def __init__( self._prefer_yuyv: bool = False + # Optional camera view around the workspace, drawn on the work + # surface only outside the workspace rectangle. + self._outside_view_enabled: bool = False + self._outside_view_margin_mm: float = 30.0 + self._outside_view_transparency: float = 0.65 + self._resolution: tuple[int, int] | None = None # Lens calibration parameters @@ -319,6 +328,82 @@ def prefer_yuyv(self, value: bool): self.changed.send(self) self.settings_changed.send(self) + @property + def outside_view_enabled(self) -> bool: + """Whether to show the camera image around the workspace.""" + return self._outside_view_enabled + + @outside_view_enabled.setter + def outside_view_enabled(self, value: bool): + if not isinstance(value, bool): + raise TypeError("outside_view_enabled must be a boolean.") + if self._outside_view_enabled == value: + return + logger.debug( + f"Camera outside_view_enabled changed from " + f"{self._outside_view_enabled} to {value}" + ) + self._outside_view_enabled = value + self.changed.send(self) + self.settings_changed.send(self) + + @property + def outside_view_margin_mm(self) -> float: + """Margin shown on each side of the workspace, in mm.""" + return self._outside_view_margin_mm + + @outside_view_margin_mm.setter + def outside_view_margin_mm(self, value: float): + if not isinstance(value, (int, float)) or isinstance(value, bool): + raise TypeError("Outside view margin must be a number.") + if not math.isfinite(value): + raise ValueError("Outside view margin must be finite.") + if not (0.0 <= value <= OUTSIDE_VIEW_MAX_MARGIN_MM): + logger.warning( + f"Outside view margin {value} is outside range " + f"(0.0-{OUTSIDE_VIEW_MAX_MARGIN_MM}). " + "Clamping to nearest bound." + ) + value = max(0.0, min(value, OUTSIDE_VIEW_MAX_MARGIN_MM)) + value = float(value) + if self._outside_view_margin_mm == value: + return + logger.debug( + f"Camera outside_view_margin_mm changed from " + f"{self._outside_view_margin_mm} to {value}" + ) + self._outside_view_margin_mm = value + self.changed.send(self) + self.settings_changed.send(self) + + @property + def outside_view_transparency(self) -> float: + """Visibility of the outside view; higher is more visible.""" + return self._outside_view_transparency + + @outside_view_transparency.setter + def outside_view_transparency(self, value: float): + if not isinstance(value, (int, float)) or isinstance(value, bool): + raise TypeError("Outside view transparency must be a number.") + if not math.isfinite(value): + raise ValueError("Outside view transparency must be finite.") + if not (0.0 <= value <= 1.0): + logger.warning( + f"Outside view transparency {value} is outside range " + "(0.0-1.0). Clamping to nearest bound." + ) + value = max(0.0, min(value, 1.0)) + value = float(value) + if self._outside_view_transparency == value: + return + logger.debug( + f"Camera outside_view_transparency changed from " + f"{self._outside_view_transparency} to {value}" + ) + self._outside_view_transparency = value + self.changed.send(self) + self.settings_changed.send(self) + @property def resolution(self) -> tuple[int, int] | None: return self._resolution @@ -656,6 +741,9 @@ def to_dict(self) -> dict[str, Any]: "transparency": self.transparency, "denoise": self.denoise, "prefer_yuyv": self.prefer_yuyv, + "outside_view_enabled": self.outside_view_enabled, + "outside_view_margin_mm": self.outside_view_margin_mm, + "outside_view_transparency": self.outside_view_transparency, "distortion_k1": self.distortion_k1, "distortion_k2": self.distortion_k2, "distortion_p1": self.distortion_p1, @@ -725,6 +813,9 @@ def from_dict(cls, data: dict[str, Any]) -> "Camera": "transparency", "denoise", "prefer_yuyv", + "outside_view_enabled", + "outside_view_margin_mm", + "outside_view_transparency", "image_to_world", "distortion_k1", "distortion_k2", @@ -768,6 +859,15 @@ def from_dict(cls, data: dict[str, Any]) -> "Camera": camera.transparency = data.get("transparency", camera.transparency) camera.denoise = data.get("denoise", 0.0) camera.prefer_yuyv = data.get("prefer_yuyv", False) + camera.outside_view_enabled = data.get( + "outside_view_enabled", camera.outside_view_enabled + ) + camera.outside_view_margin_mm = data.get( + "outside_view_margin_mm", camera.outside_view_margin_mm + ) + camera.outside_view_transparency = data.get( + "outside_view_transparency", camera.outside_view_transparency + ) camera.distortion_k1 = data.get("distortion_k1", 0.0) camera.distortion_k2 = data.get("distortion_k2", 0.0) diff --git a/rayforge/camera/outside_view.py b/rayforge/camera/outside_view.py new file mode 100644 index 000000000..f02a0a929 --- /dev/null +++ b/rayforge/camera/outside_view.py @@ -0,0 +1,321 @@ +""" +Geometry for the optional camera view outside the workspace. + +The workspace image is warped from the lens-corrected frame. Lens +correction keeps the frame size, so a strong barrel correction pushes +the frame edges out of the corrected image and drops them. The outside +view therefore samples the raw frame instead: every output pixel is +mapped from world coordinates through the alignment homography into the +lens-corrected image, and from there through the lens distortion model +into the raw frame. Without lens correction the corrected and raw frames +coincide and a plain perspective warp is used. +""" + +import math +from dataclasses import dataclass + +import cv2 +import numpy as np + +Pos = tuple[float, float] +Area = tuple[Pos, Pos] + +# Upper bound for the normalized undistorted radius searched for the +# limit of the distortion model. +_RADIAL_SEARCH_MAX = 4.0 +_RADIAL_SEARCH_STEPS = 4000 +# Keep away from the radius where the distortion model folds back. +_RADIAL_SAFETY = 0.98 +# Number of grid samples per axis used to measure lens coverage. +_COVERAGE_SAMPLES = 512 + + +@dataclass(frozen=True) +class OutsideView: + """The camera image around the workspace, for display only. + + Attributes: + image: Premultiplied BGRA image covering ``area``; pixels without + camera coverage are fully transparent. + area: Physical area ((x_min, y_min), (x_max, y_max)) in mm. + margin_mm: The effective margin around the workspace, in mm. + """ + + image: np.ndarray + area: Area + margin_mm: float + + +@dataclass(frozen=True) +class LensModel: + """The lens correction applied to a frame, in OpenCV conventions.""" + + camera_matrix: np.ndarray + distortion: np.ndarray + + def key(self) -> tuple: + return ( + np.asarray(self.camera_matrix, dtype=np.float64).tobytes(), + np.asarray(self.distortion, dtype=np.float64).tobytes(), + ) + + +def expand_area(area: Area, margin: float) -> Area: + (x_min, y_min), (x_max, y_max) = area + return (x_min - margin, y_min - margin), (x_max + margin, y_max + margin) + + +def native_output_size( + H: np.ndarray, + physical_area: Area, + margin_mm: float, + max_dimension: int, +) -> tuple[int, int]: + """ + Size an expanded output at the camera's pixel density. + + The density is measured along the workspace edges in the corrected + frame, using the sharper of both axes, since rendering finer than the + camera resolves adds no detail. It depends only on the alignment, not + on the display size, so resizing the window does not change it. + """ + (x_min, y_min), (x_max, y_max) = physical_area + corners = ( + np.array( + [ + [x_min, y_min, 1], + [x_max, y_min, 1], + [x_max, y_max, 1], + [x_min, y_max, 1], + ], + dtype=np.float64, + ) + @ H.T + ) + width_mm = x_max - x_min + height_mm = y_max - y_min + density = 0.0 + if np.all(corners[:, 2] > 0): + px = corners[:, :2] / corners[:, 2:3] + edges = np.linalg.norm(px - np.roll(px, -1, axis=0), axis=1) + density = max( + (edges[0] + edges[2]) / (2 * width_mm), + (edges[1] + edges[3]) / (2 * height_mm), + ) + width = width_mm + 2 * margin_mm + height = height_mm + 2 * margin_mm + if not math.isfinite(density) or density <= 0: + density = max_dimension / max(width, height) + width *= density + height *= density + largest = max(width, height) + if largest > max_dimension: + width *= max_dimension / largest + height *= max_dimension / largest + return max(1, round(width)), max(1, round(height)) + + +def output_to_world(output_size: tuple[int, int], area: Area) -> np.ndarray: + """The matrix mapping output pixels to world coordinates (Y-up).""" + (x_min, y_min), (x_max, y_max) = area + width_px, height_px = output_size + return np.array( + [ + [(x_max - x_min) / width_px, 0, x_min], + [0, -(y_max - y_min) / height_px, y_max], + [0, 0, 1], + ], + dtype=np.float64, + ) + + +def _distance_beyond(xs: np.ndarray, ys: np.ndarray, area: Area) -> float: + """The largest distance by which points lie outside an area.""" + if xs.size == 0: + return 0.0 + (x_min, y_min), (x_max, y_max) = area + extent = max( + x_min - xs.min(), + xs.max() - x_max, + y_min - ys.min(), + ys.max() - y_max, + ) + return max(0.0, float(extent)) + + +def frame_coverage_extent( + H: np.ndarray, frame_shape: tuple[int, ...], physical_area: Area +) -> float: + """ + How far, in mm, an undistorted frame reaches beyond the given area. + + Projects the corners of the frame into world coordinates and returns + the largest distance by which they extend past any side of + ``physical_area``, or 0 if they do not. A corner at or beyond the + horizon of the plane counts as unbounded. + """ + height, width = frame_shape[:2] + corners = np.array( + [[0, 0, 1], [width, 0, 1], [width, height, 1], [0, height, 1]], + dtype=np.float64, + ) + projected = corners @ np.linalg.inv(H).T + w = projected[:, 2] + if np.any(w <= 0) or not np.all(np.isfinite(projected)): + return math.inf + return _distance_beyond( + projected[:, 0] / w, projected[:, 1] / w, physical_area + ) + + +def radial_limit(distortion: np.ndarray) -> float: + """ + The largest normalized undistorted radius the radial distortion model + maps one-to-one. + + Beyond this radius r * (1 + k1 r^2 + k2 r^4 + k3 r^6) stops growing, + so the model folds back and would mirror image content. + """ + k1, k2, _p1, _p2, k3 = (list(np.ravel(distortion)) + [0.0] * 5)[:5] + r = np.linspace(0.0, _RADIAL_SEARCH_MAX, _RADIAL_SEARCH_STEPS) + r2 = r * r + slope = 1 + 3 * k1 * r2 + 5 * k2 * r2**2 + 7 * k3 * r2**3 + folded = np.nonzero(slope <= 0)[0] + if folded.size == 0: + return math.inf + return float(r[folded[0]]) * _RADIAL_SAFETY + + +def world_to_raw( + H: np.ndarray, + lens: LensModel, + xs: np.ndarray, + ys: np.ndarray, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """ + Map world points to raw frame pixel coordinates. + + The homography gives the position in the lens-corrected frame, which + is then distorted with the lens model, exactly inverting the + correction applied by ``cv2.undistort`` with the same camera matrix. + + Returns: + Raw x and y pixel coordinates, and a mask of points for which the + mapping is valid (in front of the camera and within the range + the distortion model maps one-to-one). + """ + u = H[0, 0] * xs + H[0, 1] * ys + H[0, 2] + v = H[1, 0] * xs + H[1, 1] * ys + H[1, 2] + w = H[2, 0] * xs + H[2, 1] * ys + H[2, 2] + valid = w > 1e-12 + w = np.where(valid, w, 1.0) + u = u / w + v = v / w + + K = lens.camera_matrix + fx, fy, cx, cy = K[0, 0], K[1, 1], K[0, 2], K[1, 2] + k1, k2, p1, p2, k3 = (list(np.ravel(lens.distortion)) + [0.0] * 5)[:5] + x = (u - cx) / fx + y = (v - cy) / fy + r2 = x * x + y * y + limit = radial_limit(lens.distortion) + valid &= r2 < limit * limit + radial = 1 + k1 * r2 + k2 * r2 * r2 + k3 * r2 * r2 * r2 + xd = x * radial + 2 * p1 * x * y + p2 * (r2 + 2 * x * x) + yd = y * radial + p1 * (r2 + 2 * y * y) + 2 * p2 * x * y + return fx * xd + cx, fy * yd + cy, valid + + +def lens_coverage_extent( + H: np.ndarray, + lens: LensModel, + frame_shape: tuple[int, ...], + physical_area: Area, + margin_mm: float, +) -> float: + """ + How far, in mm and up to ``margin_mm``, the raw frame reaches beyond + the given area. + + Samples the expanded area on a grid, maps every sample into the raw + frame and measures the valid samples that land inside it. + """ + (x_min, y_min), (x_max, y_max) = expand_area(physical_area, margin_mm) + xs, ys = np.meshgrid( + np.linspace(x_min, x_max, _COVERAGE_SAMPLES), + np.linspace(y_min, y_max, _COVERAGE_SAMPLES), + ) + raw_x, raw_y, valid = world_to_raw(H, lens, xs, ys) + height, width = frame_shape[:2] + valid &= (raw_x >= 0) & (raw_x <= width - 1) + valid &= (raw_y >= 0) & (raw_y <= height - 1) + extent = _distance_beyond(xs[valid], ys[valid], physical_area) + return min(extent, margin_mm) + + +def lens_remap_maps( + H: np.ndarray, + lens: LensModel, + output_size: tuple[int, int], + area: Area, +) -> tuple[np.ndarray, np.ndarray]: + """ + Build ``cv2.remap`` maps sampling the raw frame for an output area. + + Output pixels without a valid mapping point outside the frame, so + they come out transparent. + """ + width_px, height_px = output_size + cols, rows = np.meshgrid( + np.arange(width_px, dtype=np.float64), + np.arange(height_px, dtype=np.float64), + ) + T = output_to_world(output_size, area) + xs = T[0, 0] * cols + T[0, 2] + ys = T[1, 1] * rows + T[1, 2] + raw_x, raw_y, valid = world_to_raw(H, lens, xs, ys) + raw_x = np.where(valid, raw_x, -1.0).astype(np.float32) + raw_y = np.where(valid, raw_y, -1.0).astype(np.float32) + return cv2.convertMaps(raw_x, raw_y, cv2.CV_16SC2) + + +def to_opaque_bgra(image: np.ndarray) -> np.ndarray: + """An opaque BGRA copy of a frame.""" + bgr = np.ascontiguousarray(image[:, :, :3]) + return cv2.cvtColor(bgr, cv2.COLOR_BGR2BGRA) + + +def remap_transparent( + image: np.ndarray, maps: tuple[np.ndarray, np.ndarray] +) -> np.ndarray: + """ + Remap an opaque BGRA image, transparent where there is no source. + + Interpolating an opaque image against a transparent border yields + premultiplied alpha, including partial edge pixels. + """ + return cv2.remap( + image, + maps[0], + maps[1], + cv2.INTER_LINEAR, + borderMode=cv2.BORDER_CONSTANT, + borderValue=(0, 0, 0, 0), + ) + + +def warp_transparent( + image: np.ndarray, + H: np.ndarray, + output_size: tuple[int, int], + area: Area, +) -> np.ndarray: + """Warp an opaque BGRA image to an area, transparent elsewhere.""" + M = H @ output_to_world(output_size, area) + return cv2.warpPerspective( + image, + np.linalg.inv(M), + output_size, + borderMode=cv2.BORDER_CONSTANT, + borderValue=(0, 0, 0, 0), + ) diff --git a/rayforge/locale/de/LC_MESSAGES/rayforge.po b/rayforge/locale/de/LC_MESSAGES/rayforge.po index c24892115..649d83f2f 100644 --- a/rayforge/locale/de/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/de/LC_MESSAGES/rayforge.po @@ -10470,3 +10470,35 @@ msgstr "Bayer 4" #: rayforge/image/dither.py msgid "Bayer 8" msgstr "Bayer 8" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "View Outside Workspace" +msgstr "Ansicht außerhalb des Arbeitsbereichs" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Show the camera image in a margin around the work area to help position workpieces. Requires image alignment." +msgstr "Das Kamerabild in einem Rand um den Arbeitsbereich anzeigen, um Werkstücke zu positionieren. Erfordert eine Bildausrichtung." + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Show Outside Workspace" +msgstr "Außerhalb des Arbeitsbereichs anzeigen" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Draw the camera image around the work area" +msgstr "Das Kamerabild um den Arbeitsbereich herum zeichnen" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Not shown until the camera image is aligned" +msgstr "Wird erst angezeigt, wenn das Kamerabild ausgerichtet ist" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Margin" +msgstr "Rand" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Size of the visible area on each side of the work area" +msgstr "Größe des sichtbaren Bereichs auf jeder Seite des Arbeitsbereichs" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Transparency outside the work area" +msgstr "Transparenz außerhalb des Arbeitsbereichs" diff --git a/rayforge/locale/en/LC_MESSAGES/rayforge.po b/rayforge/locale/en/LC_MESSAGES/rayforge.po index a8c6a76cf..2d838a188 100644 --- a/rayforge/locale/en/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/en/LC_MESSAGES/rayforge.po @@ -9828,3 +9828,35 @@ msgstr "" #: rayforge/image/dither.py msgid "Bayer 8" msgstr "" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "View Outside Workspace" +msgstr "" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Show the camera image in a margin around the work area to help position workpieces. Requires image alignment." +msgstr "" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Show Outside Workspace" +msgstr "" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Draw the camera image around the work area" +msgstr "" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Not shown until the camera image is aligned" +msgstr "" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Margin" +msgstr "" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Size of the visible area on each side of the work area" +msgstr "" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Transparency outside the work area" +msgstr "" diff --git a/rayforge/locale/es/LC_MESSAGES/rayforge.po b/rayforge/locale/es/LC_MESSAGES/rayforge.po index 9a798cfc7..1689818d6 100644 --- a/rayforge/locale/es/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/es/LC_MESSAGES/rayforge.po @@ -10444,3 +10444,35 @@ msgstr "Bayer 4" #: rayforge/image/dither.py msgid "Bayer 8" msgstr "Bayer 8" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "View Outside Workspace" +msgstr "Vista fuera del área de trabajo" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Show the camera image in a margin around the work area to help position workpieces. Requires image alignment." +msgstr "Muestra la imagen de la cámara en un margen alrededor del área de trabajo para ayudar a posicionar las piezas. Requiere la alineación de la imagen." + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Show Outside Workspace" +msgstr "Mostrar fuera del área de trabajo" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Draw the camera image around the work area" +msgstr "Dibujar la imagen de la cámara alrededor del área de trabajo" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Not shown until the camera image is aligned" +msgstr "No se muestra hasta que la imagen de la cámara esté alineada" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Margin" +msgstr "Margen" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Size of the visible area on each side of the work area" +msgstr "Tamaño del área visible a cada lado del área de trabajo" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Transparency outside the work area" +msgstr "Transparencia fuera del área de trabajo" diff --git a/rayforge/locale/fr/LC_MESSAGES/rayforge.po b/rayforge/locale/fr/LC_MESSAGES/rayforge.po index a39af8cae..3c5157b91 100644 --- a/rayforge/locale/fr/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/fr/LC_MESSAGES/rayforge.po @@ -10491,3 +10491,35 @@ msgstr "Bayer 4" #: rayforge/image/dither.py msgid "Bayer 8" msgstr "Bayer 8" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "View Outside Workspace" +msgstr "Vue hors de la zone de travail" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Show the camera image in a margin around the work area to help position workpieces. Requires image alignment." +msgstr "Affiche l’image de la caméra dans une marge autour de la zone de travail pour aider à positionner les pièces. Nécessite l’alignement de l’image." + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Show Outside Workspace" +msgstr "Afficher hors de la zone de travail" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Draw the camera image around the work area" +msgstr "Dessiner l’image de la caméra autour de la zone de travail" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Not shown until the camera image is aligned" +msgstr "Non affichée tant que l’image de la caméra n’est pas alignée" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Margin" +msgstr "Marge" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Size of the visible area on each side of the work area" +msgstr "Taille de la zone visible de chaque côté de la zone de travail" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Transparency outside the work area" +msgstr "Transparence hors de la zone de travail" diff --git a/rayforge/locale/hi/LC_MESSAGES/rayforge.po b/rayforge/locale/hi/LC_MESSAGES/rayforge.po index a06a87e8e..321b4c92f 100644 --- a/rayforge/locale/hi/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/hi/LC_MESSAGES/rayforge.po @@ -10150,3 +10150,35 @@ msgstr "Bayer 4" #: rayforge/image/dither.py msgid "Bayer 8" msgstr "Bayer 8" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "View Outside Workspace" +msgstr "कार्य क्षेत्र के बाहर का दृश्य" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Show the camera image in a margin around the work area to help position workpieces. Requires image alignment." +msgstr "वर्कपीस को स्थित करने में मदद के लिए कार्य क्षेत्र के चारों ओर एक मार्जिन में कैमरा छवि दिखाएँ। छवि संरेखण आवश्यक है।" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Show Outside Workspace" +msgstr "कार्य क्षेत्र के बाहर दिखाएँ" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Draw the camera image around the work area" +msgstr "कार्य क्षेत्र के चारों ओर कैमरा छवि बनाएँ" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Not shown until the camera image is aligned" +msgstr "कैमरा छवि संरेखित होने तक नहीं दिखाई जाती" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Margin" +msgstr "मार्जिन" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Size of the visible area on each side of the work area" +msgstr "कार्य क्षेत्र के प्रत्येक ओर दृश्य क्षेत्र का आकार" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Transparency outside the work area" +msgstr "कार्य क्षेत्र के बाहर पारदर्शिता" diff --git a/rayforge/locale/pt/LC_MESSAGES/rayforge.po b/rayforge/locale/pt/LC_MESSAGES/rayforge.po index d5f66bd3f..26dc66a11 100644 --- a/rayforge/locale/pt/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/pt/LC_MESSAGES/rayforge.po @@ -10396,3 +10396,35 @@ msgstr "Bayer 4" #: rayforge/image/dither.py msgid "Bayer 8" msgstr "Bayer 8" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "View Outside Workspace" +msgstr "Vista fora da área de trabalho" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Show the camera image in a margin around the work area to help position workpieces. Requires image alignment." +msgstr "Mostra a imagem da câmera em uma margem ao redor da área de trabalho para ajudar a posicionar as peças. Requer o alinhamento da imagem." + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Show Outside Workspace" +msgstr "Mostrar fora da área de trabalho" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Draw the camera image around the work area" +msgstr "Desenhar a imagem da câmera ao redor da área de trabalho" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Not shown until the camera image is aligned" +msgstr "Não é mostrada até que a imagem da câmera esteja alinhada" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Margin" +msgstr "Margem" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Size of the visible area on each side of the work area" +msgstr "Tamanho da área visível em cada lado da área de trabalho" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Transparency outside the work area" +msgstr "Transparência fora da área de trabalho" diff --git a/rayforge/locale/rayforge.pot b/rayforge/locale/rayforge.pot index 61ccf8d71..45b15cfa9 100644 --- a/rayforge/locale/rayforge.pot +++ b/rayforge/locale/rayforge.pot @@ -9829,3 +9829,35 @@ msgstr "" #: rayforge/image/dither.py msgid "Bayer 8" msgstr "" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "View Outside Workspace" +msgstr "" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Show the camera image in a margin around the work area to help position workpieces. Requires image alignment." +msgstr "" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Show Outside Workspace" +msgstr "" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Draw the camera image around the work area" +msgstr "" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Not shown until the camera image is aligned" +msgstr "" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Margin" +msgstr "" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Size of the visible area on each side of the work area" +msgstr "" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Transparency outside the work area" +msgstr "" diff --git a/rayforge/locale/uk/LC_MESSAGES/rayforge.po b/rayforge/locale/uk/LC_MESSAGES/rayforge.po index 2f0df73fe..f7434d681 100644 --- a/rayforge/locale/uk/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/uk/LC_MESSAGES/rayforge.po @@ -10356,3 +10356,35 @@ msgstr "Bayer 4" #: rayforge/image/dither.py msgid "Bayer 8" msgstr "Bayer 8" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "View Outside Workspace" +msgstr "Вигляд поза робочою областю" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Show the camera image in a margin around the work area to help position workpieces. Requires image alignment." +msgstr "Показувати зображення камери в полі навколо робочої області, щоб допомогти розташувати заготовки. Потребує вирівнювання зображення." + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Show Outside Workspace" +msgstr "Показувати поза робочою областю" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Draw the camera image around the work area" +msgstr "Малювати зображення камери навколо робочої області" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Not shown until the camera image is aligned" +msgstr "Не показується, доки зображення камери не вирівняно" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Margin" +msgstr "Поле" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Size of the visible area on each side of the work area" +msgstr "Розмір видимої області з кожного боку робочої області" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Transparency outside the work area" +msgstr "Прозорість поза робочою областю" diff --git a/rayforge/locale/zh_CN/LC_MESSAGES/rayforge.po b/rayforge/locale/zh_CN/LC_MESSAGES/rayforge.po index 9a23e3ab0..5efae490f 100644 --- a/rayforge/locale/zh_CN/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/zh_CN/LC_MESSAGES/rayforge.po @@ -9957,3 +9957,35 @@ msgstr "Bayer 4" #: rayforge/image/dither.py msgid "Bayer 8" msgstr "Bayer 8" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "View Outside Workspace" +msgstr "工作区域外视图" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Show the camera image in a margin around the work area to help position workpieces. Requires image alignment." +msgstr "在工作区域周围的边距中显示相机图像,以帮助定位工件。需要图像对齐。" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Show Outside Workspace" +msgstr "显示工作区域外部" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Draw the camera image around the work area" +msgstr "在工作区域周围绘制相机图像" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Not shown until the camera image is aligned" +msgstr "相机图像对齐后才会显示" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Margin" +msgstr "边距" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Size of the visible area on each side of the work area" +msgstr "工作区域每侧可见区域的大小" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Transparency outside the work area" +msgstr "工作区域外的透明度" diff --git a/rayforge/ui_gtk/camera/camera_preferences_page.py b/rayforge/ui_gtk/camera/camera_preferences_page.py index ccbd530a8..5483a69dd 100644 --- a/rayforge/ui_gtk/camera/camera_preferences_page.py +++ b/rayforge/ui_gtk/camera/camera_preferences_page.py @@ -11,6 +11,7 @@ from ..shared.preferences_group import PreferencesGroupWithButton from ..shared.preferences_page import TrackedPreferencesPage from ..shared.slider import create_slider +from .outside_view_widget import CameraOutsideViewGroup from .properties_widget import CameraProperties from .selection_dialog import CameraSelectionDialog @@ -342,6 +343,10 @@ def __init__(self, **kwargs): self.camera_properties_widget = CameraProperties(None) self.add(self.camera_properties_widget) + # Display of the camera image around the workspace. + self.camera_outside_view_widget = CameraOutsideViewGroup() + self.add(self.camera_outside_view_widget) + # Connect signals self.camera_list_editor.add_requested.connect(self.on_add_camera) self.camera_list_editor.remove_requested.connect(self.on_remove_camera) @@ -421,6 +426,8 @@ def on_camera_selected(self, listbox, row): ) self.selected_controller = selected_controller self.camera_properties_widget.set_controller(selected_controller) + self.camera_outside_view_widget.set_controller(selected_controller) else: self.selected_controller = None self.camera_properties_widget.set_controller(None) + self.camera_outside_view_widget.set_controller(None) diff --git a/rayforge/ui_gtk/camera/outside_view_widget.py b/rayforge/ui_gtk/camera/outside_view_widget.py new file mode 100644 index 000000000..33b753e9d --- /dev/null +++ b/rayforge/ui_gtk/camera/outside_view_widget.py @@ -0,0 +1,131 @@ +import logging +from gettext import gettext as _ + +from gi.repository import Adw, Gtk + +from ...camera.controller import CameraController +from ...camera.models.camera import OUTSIDE_VIEW_MAX_MARGIN_MM, Camera +from ..shared.pref_rows import LengthSpinRow +from ..shared.slider import create_slider_row + +logger = logging.getLogger(__name__) + + +class CameraOutsideViewGroup(Adw.PreferencesGroup): + """Settings for showing the camera image around the workspace.""" + + def __init__(self, **kwargs): + super().__init__( + title=_("View Outside Workspace"), + description=_( + "Show the camera image in a margin around the work area " + "to help position workpieces. Requires image alignment." + ), + **kwargs, + ) + self._camera: Camera | None = None + self._updating_ui: bool = False + + self.enabled_row = Adw.ActionRow( + title=_("Show Outside Workspace"), + subtitle=_("Draw the camera image around the work area"), + ) + self.enabled_switch = Gtk.Switch() + self.enabled_switch.set_valign(Gtk.Align.CENTER) + self.enabled_switch.connect("notify::active", self._on_enabled_changed) + self.enabled_row.add_suffix(self.enabled_switch) + self.enabled_row.set_activatable_widget(self.enabled_switch) + self.add(self.enabled_row) + + self.margin_row = LengthSpinRow( + _("Margin"), + _("Size of the visible area on each side of the work area"), + lower=0.0, + upper=OUTSIDE_VIEW_MAX_MARGIN_MM, + step_increment=1.0, + digits=0, + numeric=True, + ) + self.margin_row.value_changed.connect(self._on_margin_changed) + self.add(self.margin_row) + + self.transparency_adjustment = Gtk.Adjustment( + value=0.0, + lower=0.0, + upper=1.0, + step_increment=0.01, + page_increment=0.1, + ) + self.transparency_row, self.transparency_scale = create_slider_row( + title=_("Transparency"), + subtitle=_("Transparency outside the work area"), + adjustment=self.transparency_adjustment, + digits=2, + on_value_changed=self._on_transparency_changed, + ) + self.add(self.transparency_row) + + self.set_controller(None) + + def set_controller(self, controller: CameraController | None): + if self._camera: + self._camera.changed.disconnect(self._on_camera_changed) + + self._camera = controller.config if controller else None + + if self._camera: + self._camera.changed.connect(self._on_camera_changed) + self.set_sensitive(True) + else: + self.set_sensitive(False) + self.update_ui() + + def update_ui(self): + if self._updating_ui: + return + self._updating_ui = True + try: + camera = self._camera + enabled = bool(camera and camera.outside_view_enabled) + self.enabled_switch.set_active(enabled) + self.margin_row.set_sensitive(enabled) + self.transparency_row.set_sensitive(enabled) + if camera: + self.margin_row.set_value_in_base_units( + camera.outside_view_margin_mm + ) + self.transparency_adjustment.set_value( + camera.outside_view_transparency + ) + self._update_alignment_hint() + finally: + self._updating_ui = False + + def _update_alignment_hint(self): + camera = self._camera + if camera and not camera.has_alignment: + self.enabled_row.set_subtitle( + _("Not shown until the camera image is aligned") + ) + else: + self.enabled_row.set_subtitle( + _("Draw the camera image around the work area") + ) + + def _on_camera_changed(self, camera, *args): + self.update_ui() + + def _on_enabled_changed(self, switch, _pspec): + if self._updating_ui or not self._camera: + return + self._camera.outside_view_enabled = switch.get_active() + + def _on_margin_changed(self, row): + if self._updating_ui or not self._camera: + return + self._camera.outside_view_margin_mm = row.get_value_in_base_units() + + def _on_transparency_changed(self, scale): + if self._updating_ui or not self._camera: + return + self._camera.outside_view_transparency = scale.get_value() diff --git a/rayforge/ui_gtk/canvas2d/elements/camera_image.py b/rayforge/ui_gtk/canvas2d/elements/camera_image.py index bc31df060..b0e4fd9d7 100644 --- a/rayforge/ui_gtk/canvas2d/elements/camera_image.py +++ b/rayforge/ui_gtk/canvas2d/elements/camera_image.py @@ -9,6 +9,7 @@ from gi.repository import GLib from ....camera.controller import CameraController +from ....camera.outside_view import OutsideView from ...canvas import CanvasElement if TYPE_CHECKING: @@ -22,14 +23,55 @@ MAX_PROCESSING_DIMENSION = 2048 +def _processing_size(output_size: tuple[int, int]) -> tuple[int, int]: + """Caps an output size to MAX_PROCESSING_DIMENSION.""" + processing_width, processing_height = output_size + if max(processing_width, processing_height) > MAX_PROCESSING_DIMENSION: + scale = MAX_PROCESSING_DIMENSION / max( + processing_width, processing_height + ) + processing_width = round(processing_width * scale) + processing_height = round(processing_height * scale) + return processing_width, processing_height + + +def _surface_for_data( + data: np.ndarray, +) -> tuple[cairo.ImageSurface, np.ndarray]: + """Wraps a BGRA buffer in a Cairo surface, returning both.""" + height, width, _ = data.shape + surface_data = np.ascontiguousarray(data) + surface = cairo.ImageSurface.create_for_data( + surface_data, # type: ignore + cairo.FORMAT_ARGB32, + width, + height, # type: ignore + ) + return surface, surface_data + + class CameraImageElement(CanvasElement): - def __init__(self, controller: CameraController, **kwargs): + def __init__( + self, + controller: CameraController, + outside_view_supported: bool = False, + **kwargs, + ): # We are not a standard buffered element because we manage our own # surface cache to prevent flicker. We will use a custom draw() method. + # With outside view support the element draws beyond its bounds, so + # it clips the workspace image itself instead. super().__init__( - x=0, y=0, width=1.0, height=1.0, buffered=False, **kwargs + x=0, + y=0, + width=1.0, + height=1.0, + buffered=False, + clip=not outside_view_supported, + **kwargs, ) self.selectable = False + self.outside_view_supported = outside_view_supported self.controller = controller self.camera = controller.config # Convenience alias for the data model self.controller.image_captured.connect(self._on_state_changed) @@ -42,6 +84,13 @@ def __init__(self, controller: CameraController, **kwargs): self._cached_surface_data: np.ndarray | None = None # A key representing the state that generated the cached surface. self._cached_key: tuple | None = None + # The key of the processing job that is scheduled but not yet run. + self._pending_key: tuple | None = None + # Cache for the optional view outside the workspace: its surface, + # data buffer, view, and the outside signature it was made for. + self._outside_cache: ( + tuple[cairo.ImageSurface, np.ndarray, OutsideView, tuple] | None + ) = None def _on_camera_model_changed(self, sender): """ @@ -95,11 +144,17 @@ def draw(self, ctx: cairo.Context): bounds, and triggers a recomputation if the camera state has changed. """ assert self.canvas, "Canvas must be set before drawing" - worksurface = cast("WorkSurface", self.canvas) + outside = self._valid_outside_cache() # 1. Draw the last valid computed surface to prevent flicker. if self._cached_surface: ctx.save() + if not self.clip: + if outside: + # Pixel-exact edges keep the two passes seamless. + ctx.set_antialias(cairo.ANTIALIAS_NONE) + ctx.rectangle(0, 0, self.width, self.height) + ctx.clip() source_w = self._cached_surface.get_width() source_h = self._cached_surface.get_height() @@ -131,51 +186,141 @@ def draw(self, ctx: cairo.Context): ctx.restore() + if outside: + self._draw_outside(ctx, outside[0], outside[2]) + # 2. Check if a new surface needs to be computed. - # The output size for the recomputation should be the pixel dimensions - # of the canvas widget itself, not the mm dimensions of the work area. - output_width = self.canvas.get_width() - output_height = self.canvas.get_height() + current_key = self._current_key() + if current_key is None: + return + # 3. Recompute if needed, but in a non-blocking way. if ( - self.controller.image_data is None - or output_width <= 0 - or output_height <= 0 + self._cached_key != current_key + and self._pending_key != current_key ): - return + self._pending_key = current_key + GLib.idle_add(self._process_and_update_cache, current_key) - physical_area = None - if self.camera.image_to_world: - physical_area = ( - (0, 0), - (worksurface.width_mm, worksurface.height_mm), - ) + def _physical_area(self) -> tuple | None: + """The workspace area to align to, or None without alignment.""" + if not self.camera.image_to_world: + return None + worksurface = cast("WorkSurface", self.canvas) + return ((0, 0), (worksurface.width_mm, worksurface.height_mm)) - current_key = ( - id(self.controller.image_data), + def _outside_margin_request(self) -> float | None: + """The outside view margin to render, or None to skip the pass.""" + camera = self.camera + if ( + not self.outside_view_supported + or not camera.outside_view_enabled + or not camera.image_to_world + or camera.outside_view_margin_mm <= 0 + or camera.outside_view_transparency <= 0 + ): + return None + return camera.outside_view_margin_mm + + def _outside_signature(self) -> tuple | None: + """Identifies the geometry an outside view is valid for.""" + margin = self._outside_margin_request() + if margin is None: + return None + return (self.camera.image_to_world, self._physical_area(), margin) + + def _valid_outside_cache(self) -> tuple | None: + """The cached outside view, if it matches the current settings.""" + if self._outside_cache is None: + return None + signature = self._outside_signature() + if signature is None or self._outside_cache[3] != signature: + return None + return self._outside_cache + + def _current_key(self) -> tuple | None: + """A key for the state the cached surfaces should reflect.""" + if not self.canvas: + return None + # The output size for the recomputation should be the pixel + # dimensions of the canvas widget itself, not the mm dimensions of + # the work area. + output_width = self.canvas.get_width() + output_height = self.canvas.get_height() + image_data = self.controller.image_data + + if image_data is None or output_width <= 0 or output_height <= 0: + return None + + return ( + id(image_data), output_width, output_height, - physical_area, + self._physical_area(), self.camera.transparency, + self._outside_margin_request(), ) - # 3. Recompute if needed, but in a non-blocking way. - if self._cached_key != current_key: - GLib.idle_add(self._process_and_update_cache, current_key) + def _draw_outside( + self, + ctx: cairo.Context, + surface: cairo.ImageSurface, + view: OutsideView, + ): + """ + Paints the outside view around, but never inside, the workspace, + at the outside view transparency. + """ + (x_min, y_min), (x_max, y_max) = view.area + area_w = x_max - x_min + area_h = y_max - y_min + source_w = surface.get_width() + source_h = surface.get_height() + if area_w <= 0 or area_h <= 0 or source_w <= 0 or source_h <= 0: + return + + ctx.save() + ctx.set_antialias(cairo.ANTIALIAS_NONE) + ctx.set_fill_rule(cairo.FILL_RULE_EVEN_ODD) + ctx.rectangle(x_min, y_min, area_w, area_h) + ctx.rectangle(0, 0, self.width, self.height) + ctx.clip() + + ctx.translate(x_min, y_min) + ctx.scale(area_w / source_w, area_h / source_h) + # The world is Y-up, but the cairo surface is Y-down. + ctx.translate(0, source_h) + ctx.scale(1, -1) + + ctx.set_source_surface(surface, 0, 0) + ctx.get_source().set_filter(cairo.FILTER_GOOD) + ctx.paint_with_alpha(self.camera.outside_view_transparency) + ctx.restore() def _process_and_update_cache(self, key_for_this_job: tuple) -> bool: """The actual work, to be run by GLib.idle_add.""" - image_data = self.controller.image_data - img_data_id, width, height, p_area, transp = key_for_this_job + if self._pending_key == key_for_this_job: + self._pending_key = None - if image_data is None or id(image_data) != img_data_id: - # A newer frame has already arrived; this job is stale. + if key_for_this_job != self._current_key(): + # A newer frame or a settings change has arrived, or the + # element was removed; this job is stale. return False # Stop the idle add - # Generate both the surface and its data buffer. - result = self._generate_surface( - image_data, (width, height), p_area, transp - ) + image_data = self.controller.image_data + assert image_data is not None + _, width, height, p_area, transp, margin = key_for_this_job + + outside = None + if p_area is not None and margin is not None: + result, outside = self._generate_surfaces( + (width, height), p_area, transp, margin + ) + else: + # Generate both the surface and its data buffer. + result = self._generate_surface( + image_data, (width, height), p_area, transp + ) if result: new_surface, new_surface_data = result @@ -183,12 +328,42 @@ def _process_and_update_cache(self, key_for_this_job: tuple) -> bool: self._cached_surface = new_surface self._cached_surface_data = new_surface_data self._cached_key = key_for_this_job + self._outside_cache = outside if self.canvas: self.canvas.queue_draw() # This function should only run once per schedule. return False + def _generate_surfaces( + self, + output_size: tuple[int, int], + physical_area: tuple, + transparency: float, + margin_mm: float, + ) -> tuple[tuple | None, tuple | None]: + """ + Creates the workspace surface and the outside view surface from a + single frame. The outside result also carries the view and the + signature it is valid for. + """ + signature = self._outside_signature() + workspace, view = self.controller.get_work_surface_images( + output_size=_processing_size(output_size), + physical_area=physical_area, + outside_margin_mm=margin_mm, + max_dimension=MAX_PROCESSING_DIMENSION, + ) + if workspace is None: + logger.warning("Image transformation failed, skipping frame.") + return None, None + + outside = None + if view is not None and signature is not None: + surface, data = _surface_for_data(view.image) + outside = (surface, data, view, signature) + return self._surface_from_image(workspace, transparency), outside + def _generate_surface( self, image_data: np.ndarray, @@ -203,19 +378,8 @@ def _generate_surface( processed_image = image_data if physical_area: - processing_width, processing_height = output_size - if ( - max(processing_width, processing_height) - > MAX_PROCESSING_DIMENSION - ): - scale = MAX_PROCESSING_DIMENSION / max( - processing_width, processing_height - ) - processing_width = round(processing_width * scale) - processing_height = round(processing_height * scale) - transformed_image = self.controller.get_work_surface_image( - output_size=(processing_width, processing_height), + output_size=_processing_size(output_size), physical_area=physical_area, ) @@ -224,6 +388,15 @@ def _generate_surface( return None processed_image = transformed_image + return self._surface_from_image(processed_image, transparency) + + def _surface_from_image( + self, processed_image: np.ndarray, transparency: float + ) -> tuple[cairo.ImageSurface, np.ndarray]: + """ + Applies the camera opacity to an image and creates a Cairo surface, + returning it along with its data buffer. + """ if processed_image.shape[2] == 3: bgra_image = cv2.cvtColor(processed_image, cv2.COLOR_BGR2BGRA) else: diff --git a/rayforge/ui_gtk/canvas2d/surface.py b/rayforge/ui_gtk/canvas2d/surface.py index afbe2e0c9..94a6ac8d5 100644 --- a/rayforge/ui_gtk/canvas2d/surface.py +++ b/rayforge/ui_gtk/canvas2d/surface.py @@ -1262,7 +1262,9 @@ def set_camera_controllers(self, controllers: list[CameraController]): # Add elements for new controllers for controller in new_controllers - current_controllers: - element = CameraImageElement(controller) + element = CameraImageElement( + controller, outside_view_supported=True + ) element.set_visible( self._cam_visible and controller.config.enabled ) diff --git a/tests/camera/test_camera.py b/tests/camera/test_camera.py index 7b54bb528..d13a91af7 100644 --- a/tests/camera/test_camera.py +++ b/tests/camera/test_camera.py @@ -489,3 +489,84 @@ def test_from_dict_naive_dates_become_aware(): assert camera._alignment_date is not None assert camera._alignment_date.tzinfo is not None assert camera.alignment_valid is True + + +def test_outside_view_defaults(): + camera = Camera("Test Camera", "0") + assert camera.outside_view_enabled is False + assert camera.outside_view_margin_mm == 30.0 + assert camera.outside_view_transparency == 0.65 + + +def test_outside_view_old_config_loads_with_defaults(): + camera = Camera.from_dict({"name": "Old Camera", "device_id": "0"}) + assert camera.outside_view_enabled is False + assert camera.outside_view_margin_mm == 30.0 + assert camera.outside_view_transparency == 0.65 + assert "outside_view_enabled" not in camera.extra + + +def test_outside_view_round_trip(): + camera = Camera("Test Camera", "0") + camera.outside_view_enabled = True + camera.outside_view_margin_mm = 42.5 + camera.outside_view_transparency = 0.3 + + restored = Camera.from_json(camera.to_json()) + + assert restored.outside_view_enabled is True + assert restored.outside_view_margin_mm == 42.5 + assert restored.outside_view_transparency == 0.3 + assert not any(k.startswith("outside_view") for k in restored.extra) + + +def test_outside_view_values_are_clamped(): + camera = Camera("Test Camera", "0") + camera.outside_view_margin_mm = 150 + assert camera.outside_view_margin_mm == 100.0 + camera.outside_view_margin_mm = -5 + assert camera.outside_view_margin_mm == 0.0 + camera.outside_view_transparency = 1.5 + assert camera.outside_view_transparency == 1.0 + camera.outside_view_transparency = -0.5 + assert camera.outside_view_transparency == 0.0 + + +@pytest.mark.parametrize("value", [float("nan"), float("inf"), -float("inf")]) +def test_outside_view_rejects_nonfinite(value): + camera = Camera("Test Camera", "0") + with pytest.raises(ValueError): + camera.outside_view_margin_mm = value + with pytest.raises(ValueError): + camera.outside_view_transparency = value + assert camera.outside_view_margin_mm == 30.0 + assert camera.outside_view_transparency == 0.65 + + +def test_outside_view_rejects_wrong_types(): + camera = Camera("Test Camera", "0") + with pytest.raises(TypeError): + camera.outside_view_enabled = 1 # type: ignore[assignment] + with pytest.raises(TypeError): + camera.outside_view_margin_mm = "10" # type: ignore[assignment] + with pytest.raises(TypeError): + camera.outside_view_transparency = None # type: ignore[assignment] + + +def test_outside_view_setters_emit_signals(): + camera = Camera("Test Camera", "0") + changed = [] + settings_changed = [] + camera.changed.connect(changed.append, weak=False) + camera.settings_changed.connect(settings_changed.append, weak=False) + + camera.outside_view_enabled = True + camera.outside_view_margin_mm = 10 + camera.outside_view_transparency = 0.5 + assert len(changed) == 3 + assert len(settings_changed) == 3 + + camera.outside_view_enabled = True + camera.outside_view_margin_mm = 10.0 + camera.outside_view_transparency = 0.5 + assert len(changed) == 3 diff --git a/tests/camera/test_controller.py b/tests/camera/test_controller.py index 5103717c9..ee264f687 100644 --- a/tests/camera/test_controller.py +++ b/tests/camera/test_controller.py @@ -12,6 +12,15 @@ from rayforge.camera.controller import CameraController from rayforge.camera.models.camera import Camera, CameraSourceType +from rayforge.camera.outside_view import ( + LensModel, + frame_coverage_extent, + lens_remap_maps, + radial_limit, + to_opaque_bgra, + warp_transparent, + world_to_raw, +) from rayforge.camera.source import ( CameraSource, HttpSnapshotSource, @@ -1108,3 +1117,342 @@ def test_subscribe_after_dispose_is_a_noop(): assert start_calls == [] assert controller._active_subscribers == 0 + + +def _aligned_gradient_controller() -> CameraController: + """An aligned controller whose frame encodes pixel positions. + + The alignment maps 4 px/mm in x and 3 px/mm in y, so the 640x480 + frame covers world x in [-25, 135] and y in about [-33.3, 126.7]. + """ + camera_config = Camera("Test Camera", "0") + controller = CameraController(camera_config) + image = np.zeros((480, 640, 3), dtype=np.uint8) + image[:, :, 0] = (np.arange(640) * 255 // 639)[np.newaxis, :] + image[:, :, 1] = (np.arange(480) * 255 // 479)[:, np.newaxis] + controller._image_data = image + camera_config.image_to_world = ( + [(100, 100), (500, 100), (500, 400), (100, 400)], + [(0, 100), (100, 100), (100, 0), (0, 0)], + ) + return controller + + +WORKSPACE = ((0, 0), (100, 100)) + + +def test_get_work_surface_images_workspace_matches_single_render(): + controller = _aligned_gradient_controller() + + workspace, outside = controller.get_work_surface_images( + (200, 200), WORKSPACE, 20, 2048 + ) + + expected = controller.get_work_surface_image((200, 200), WORKSPACE) + assert workspace is not None and expected is not None + np.testing.assert_array_equal(workspace, expected) + assert outside is not None + + +def test_outside_view_area_size_and_world_coordinates(): + controller = _aligned_gradient_controller() + + _, outside = controller.get_work_surface_images( + (200, 200), WORKSPACE, 20, 2048 + ) + + assert outside is not None + assert outside.margin_mm == 20 + assert outside.area == ((-20, -20), (120, 120)) + # Rendered at the camera's density of 4 px/mm, the sharper axis. + assert outside.image.shape == (560, 560, 4) + + # Every covered pixel, including all edges and corners at negative + # coordinates, matches the existing transform of the expanded area. + reference = controller.get_work_surface_image((560, 560), outside.area) + assert reference is not None + alpha = outside.image[:, :, 3] + covered = alpha == 255 + for row, col in [(0, 0), (0, -1), (-1, 0), (-1, -1), (280, 0)]: + assert covered[row, col] + np.testing.assert_array_equal( + outside.image[:, :, :3][covered], reference[covered] + ) + + +def test_outside_view_margin_is_clamped_to_camera_coverage(): + controller = _aligned_gradient_controller() + + _, outside = controller.get_work_surface_images( + (200, 200), WORKSPACE, 100, 2048 + ) + + assert outside is not None + # The frame reaches 35 mm beyond the right edge of the workspace. + assert outside.margin_mm == pytest.approx(35.0) + assert controller.config.outside_view_margin_mm == 30.0 + + +def test_outside_view_missing_coverage_is_transparent(): + controller = _aligned_gradient_controller() + controller._image_data = np.zeros((480, 640, 3), dtype=np.uint8) + + _, outside = controller.get_work_surface_images( + (200, 200), WORKSPACE, 100, 2048 + ) + + assert outside is not None + image = outside.image + # Left of x=-25 mm the camera sees nothing: fully transparent. + assert image[:, :10, 3].max() == 0 + # Genuine black pixels inside the frame stay opaque. + assert image[170, 170, 3] == 255 + np.testing.assert_array_equal(image[170, 170, :3], [0, 0, 0]) + + +def test_outside_view_is_premultiplied_with_partial_edges(): + controller = _aligned_gradient_controller() + controller._image_data = np.full((480, 640, 3), 255, dtype=np.uint8) + + # A non-integer density puts the frame edges between pixels. + _, outside = controller.get_work_surface_images( + (233, 233), WORKSPACE, 100, 2048 + ) + + assert outside is not None + alpha = outside.image[:, :, 3] + assert ((alpha > 0) & (alpha < 255)).any() + assert (outside.image[:, :, :3] <= alpha[:, :, np.newaxis]).all() + + +def test_outside_view_none_without_alignment_or_margin(): + controller = _aligned_gradient_controller() + + _, outside = controller.get_work_surface_images( + (200, 200), WORKSPACE, 0, 2048 + ) + assert outside is None + + controller.config.image_to_world = None + workspace, outside = controller.get_work_surface_images( + (200, 200), WORKSPACE, 20, 2048 + ) + assert outside is None + assert workspace is not None + + +def test_outside_view_none_without_coverage_beyond_workspace(): + controller = _aligned_gradient_controller() + + _, outside = controller.get_work_surface_images( + (200, 200), ((-50, -50), (150, 150)), 20, 2048 + ) + + assert outside is None + + +def test_outside_view_size_is_capped(): + controller = _aligned_gradient_controller() + + _, outside = controller.get_work_surface_images( + (200, 200), WORKSPACE, 20, 400 + ) + + assert outside is not None + assert outside.image.shape[:2] == (400, 400) + + +def test_get_work_surface_images_uses_a_single_frame(): + controller = _aligned_gradient_controller() + controller._image_data = np.full((480, 640, 3), 200, dtype=np.uint8) + newer_frame = np.full((480, 640, 3), 50, dtype=np.uint8) + original = controller._work_surface_from_image + + def replace_frame_midway(*args): + result = original(*args) + with controller._frame_lock: + controller._image_data = newer_frame + return result + + with patch.object( + controller, "_work_surface_from_image", replace_frame_midway + ): + workspace, outside = controller.get_work_surface_images( + (200, 200), WORKSPACE, 20, 2048 + ) + + assert workspace is not None and outside is not None + assert (workspace == 200).all() + covered = outside.image[:, :, 3] == 255 + assert (outside.image[:, :, :3][covered] == 200).all() + + +def _aligned_lens_controller(k1: float = -0.3) -> CameraController: + """An aligned gradient controller with barrel lens correction. + + The raw frame is the gradient; the processed frame is its lens + correction, which pushes the frame edges out of the image. + """ + controller = _aligned_gradient_controller() + controller.config.distortion_k1 = k1 + raw = controller._image_data + assert raw is not None + controller._raw_image_data = raw + controller._image_data = controller._process_frame(raw) + return controller + + +def test_outside_view_recovers_content_cropped_by_lens_correction(): + controller = _aligned_lens_controller() + image = controller._image_data + assert image is not None + H = controller._compute_homography(image.shape[0]) + corrected_extent = frame_coverage_extent(H, image.shape, WORKSPACE) + + _, outside = controller.get_work_surface_images( + (200, 200), WORKSPACE, 100, 2048 + ) + + assert outside is not None + assert outside.margin_mm > corrected_extent + 5 + + +def test_outside_view_from_raw_matches_corrected_frame(): + controller = _aligned_lens_controller() + image = controller._image_data + assert image is not None + + _, outside = controller.get_work_surface_images( + (200, 200), WORKSPACE, 100, 2048 + ) + + assert outside is not None + size = outside.image.shape[1::-1] + H = controller._compute_homography(image.shape[0]) + reference = warp_transparent(to_opaque_bgra(image), H, size, outside.area) + both = (reference[:, :, 3] == 255) & (outside.image[:, :, 3] == 255) + assert both.mean() > 0.3 + difference = np.abs( + reference[:, :, :3][both].astype(int) + - outside.image[:, :, :3][both].astype(int) + ) + assert difference.mean() < 2 + + +def test_world_to_raw_inverts_lens_correction(): + controller = _aligned_lens_controller() + image = controller._image_data + assert image is not None + lens = controller._lens_model(image) + assert lens is not None + corrected = np.array( + [[320.0, 240.0], [50.0, 60.0], [600.0, 420.0], [10.0, 470.0]] + ) + + raw_x, raw_y, valid = world_to_raw( + np.eye(3), lens, corrected[:, 0], corrected[:, 1] + ) + + assert valid.all() + raw = np.stack([raw_x, raw_y], axis=1).reshape(-1, 1, 2) + back = cv2.undistortPoints( + raw, lens.camera_matrix, lens.distortion, P=lens.camera_matrix + ).reshape(-1, 2) + np.testing.assert_allclose(back, corrected, atol=0.05) + + +def test_radial_limit_excludes_folding_distortion(): + assert radial_limit(np.zeros(5)) == float("inf") + + distortion = np.array([-0.98, 1.75, 0.0, 0.0, -1.75]) + limit = radial_limit(distortion) + + assert 0.6 < limit < 0.8 + lens = LensModel(np.eye(3), distortion) + _, _, valid = world_to_raw( + np.eye(3), + lens, + np.array([0.5 * limit, 1.1 * limit]), + np.zeros(2), + ) + np.testing.assert_array_equal(valid, [True, False]) + + +def test_outside_view_lens_maps_are_cached_across_frames(): + controller = _aligned_lens_controller() + + with patch( + "rayforge.camera.controller.lens_remap_maps", + wraps=lens_remap_maps, + ) as build_maps: + for value in (100, 150): + raw = np.full((480, 640, 3), value, dtype=np.uint8) + controller._raw_image_data = raw + controller._image_data = controller._process_frame(raw) + _, outside = controller.get_work_surface_images( + (200, 200), WORKSPACE, 30, 2048 + ) + assert outside is not None + + controller.config.outside_view_margin_mm = 10 + controller.get_work_surface_images((200, 200), WORKSPACE, 10, 2048) + + assert build_maps.call_count == 2 + + +def test_get_work_surface_images_uses_a_single_raw_frame(): + controller = _aligned_lens_controller() + controller._raw_image_data = np.full((480, 640, 3), 200, np.uint8) + controller._image_data = np.full((480, 640, 3), 200, np.uint8) + newer_frame = np.full((480, 640, 3), 50, dtype=np.uint8) + original = controller._work_surface_from_image + + def replace_frames_midway(*args): + result = original(*args) + with controller._frame_lock: + controller._image_data = newer_frame + controller._raw_image_data = newer_frame + return result + + with patch.object( + controller, "_work_surface_from_image", replace_frames_midway + ): + workspace, outside = controller.get_work_surface_images( + (200, 200), WORKSPACE, 20, 2048 + ) + + assert workspace is not None and outside is not None + assert (workspace == 200).all() + covered = outside.image[:, :, 3] == 255 + assert covered.any() + assert (outside.image[:, :, :3][covered] == 200).all() + + +def test_outside_view_does_not_depend_on_display_size(): + controller = _aligned_lens_controller() + + with patch( + "rayforge.camera.controller.lens_remap_maps", + wraps=lens_remap_maps, + ) as build_maps: + shapes = set() + for output_size in [(200, 200), (800, 600), (1203, 777)]: + _, outside = controller.get_work_surface_images( + output_size, WORKSPACE, 20, 2048 + ) + assert outside is not None + shapes.add(outside.image.shape) + + assert len(shapes) == 1 + assert build_maps.call_count == 1 + + +def test_disabling_outside_view_frees_cached_maps(): + controller = _aligned_lens_controller() + controller.config.outside_view_enabled = True + controller.get_work_surface_images((200, 200), WORKSPACE, 20, 2048) + assert controller._outside_cache + + controller.config.outside_view_enabled = False + + assert not controller._outside_cache diff --git a/tests/ui_gtk/camera/test_outside_view_widget.py b/tests/ui_gtk/camera/test_outside_view_widget.py new file mode 100644 index 000000000..d951c85be --- /dev/null +++ b/tests/ui_gtk/camera/test_outside_view_widget.py @@ -0,0 +1,97 @@ +# flake8: noqa: E402 +"""UI tests for the camera outside view settings group.""" + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") + +import pytest + +from rayforge.camera.controller import CameraController +from rayforge.camera.models.camera import Camera +from rayforge.ui_gtk.camera.outside_view_widget import ( + CameraOutsideViewGroup, +) + +pytestmark = pytest.mark.ui + + +def _controller(name: str) -> CameraController: + return CameraController(Camera(name, "0")) + + +def test_controls_follow_toggle_and_preserve_values(ui_context_initializer): + controller = _controller("Camera") + camera = controller.config + camera.outside_view_margin_mm = 40 + camera.outside_view_transparency = 0.4 + group = CameraOutsideViewGroup() + group.set_controller(controller) + + assert not group.enabled_switch.get_active() + assert not group.margin_row.get_sensitive() + assert not group.transparency_row.get_sensitive() + assert group.margin_row.get_value_in_base_units() == 40 + assert group.transparency_adjustment.get_value() == 0.4 + + group.enabled_switch.set_active(True) + + assert camera.outside_view_enabled + assert group.margin_row.get_sensitive() + assert group.transparency_row.get_sensitive() + + group.enabled_switch.set_active(False) + assert camera.outside_view_margin_mm == 40 + assert camera.outside_view_transparency == 0.4 + + +def test_controls_write_to_the_camera(ui_context_initializer): + controller = _controller("Camera") + group = CameraOutsideViewGroup() + group.set_controller(controller) + group.enabled_switch.set_active(True) + + group.transparency_adjustment.set_value(0.25) + group.margin_row.get_spin_button().set_value(55) + + assert controller.config.outside_view_transparency == 0.25 + assert controller.config.outside_view_margin_mm == 55 + + +def test_switching_controllers_keeps_settings_independent( + ui_context_initializer, +): + first = _controller("First") + second = _controller("Second") + first.config.outside_view_enabled = True + first.config.outside_view_margin_mm = 70 + group = CameraOutsideViewGroup() + + group.set_controller(first) + group.set_controller(second) + + assert not group.enabled_switch.get_active() + assert group.margin_row.get_value_in_base_units() == 30 + assert second.config.outside_view_margin_mm == 30 + assert first.config.outside_view_margin_mm == 70 + + first.config.outside_view_margin_mm = 80 + assert group.margin_row.get_value_in_base_units() == 30 + + group.set_controller(None) + assert not group.get_sensitive() + + +def test_alignment_hint(ui_context_initializer): + controller = _controller("Camera") + group = CameraOutsideViewGroup() + group.set_controller(controller) + unaligned = group.enabled_row.get_subtitle() + + controller.config.image_to_world = ( + [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)], + [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)], + ) + + assert group.enabled_row.get_subtitle() != unaligned diff --git a/tests/ui_gtk/canvas2d/test_camera_image.py b/tests/ui_gtk/canvas2d/test_camera_image.py new file mode 100644 index 000000000..a302081bd --- /dev/null +++ b/tests/ui_gtk/canvas2d/test_camera_image.py @@ -0,0 +1,164 @@ +"""Tests for the camera image element's optional outside view.""" + +from unittest.mock import MagicMock, patch + +import cairo +import numpy as np +import pytest + +from rayforge.camera.controller import CameraController +from rayforge.camera.models.camera import Camera +from rayforge.ui_gtk.canvas2d.elements.camera_image import ( + CameraImageElement, +) + +pytestmark = pytest.mark.ui + +INSIDE = (150, 150) +OUTSIDE = (90, 150) +BEYOND_COVERAGE = (50, 150) + + +def _controller() -> CameraController: + """An aligned 640x480 white frame covering world x in [-25, 135].""" + camera = Camera("Test Camera", "0") + camera.enabled = True + camera.image_to_world = ( + [(100, 100), (500, 100), (500, 400), (100, 400)], + [(0, 100), (100, 100), (100, 0), (0, 0)], + ) + camera.outside_view_margin_mm = 20 + controller = CameraController(camera) + controller._image_data = np.full((480, 640, 3), 255, dtype=np.uint8) + return controller + + +def _element(controller, supported=True) -> CameraImageElement: + canvas = MagicMock() + canvas.width_mm = 100.0 + canvas.height_mm = 100.0 + canvas._cam_visible = True + canvas.get_width.return_value = 200 + canvas.get_height.return_value = 200 + element = CameraImageElement(controller, outside_view_supported=supported) + element.canvas = canvas + element.allocate() + return element + + +def _process(element: CameraImageElement) -> None: + key = element._current_key() + assert key is not None + element._process_and_update_cache(key) + assert element._cached_key == key + + +def _render(element: CameraImageElement) -> np.ndarray: + """Render at 1 px/mm with world (0, 0) at pixel (100, 200).""" + surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, 300, 300) + ctx = cairo.Context(surface) + ctx.translate(100, 200) + ctx.scale(1, -1) + with patch("rayforge.ui_gtk.canvas2d.elements.camera_image.GLib"): + element.render(ctx) + surface.flush() + buf = np.frombuffer(surface.get_data(), dtype=np.uint8) + return buf.reshape(300, 300, 4) + + +def _alpha(pixels: np.ndarray, point: tuple[int, int]) -> int: + x, y = point + return int(pixels[y, x, 3]) + + +def test_outside_view_disabled_matches_existing_render(): + controller = _controller() + legacy = _element(controller, supported=False) + element = _element(controller) + _process(legacy) + with patch.object( + controller, + "get_work_surface_images", + wraps=controller.get_work_surface_images, + ) as paired: + _process(element) + paired.assert_not_called() + + np.testing.assert_array_equal(_render(element), _render(legacy)) + assert _alpha(_render(element), OUTSIDE) == 0 + + +def test_outside_view_draws_only_outside_with_own_transparency(): + controller = _controller() + element = _element(controller) + _process(element) + inside_alpha = _alpha(_render(element), INSIDE) + + controller.config.outside_view_enabled = True + _process(element) + pixels = _render(element) + + assert _alpha(pixels, INSIDE) == inside_alpha + assert _alpha(pixels, OUTSIDE) == pytest.approx(0.65 * 255, abs=1) + assert _alpha(pixels, BEYOND_COVERAGE) == 0 + # The margin ends 20 mm outside the workspace. + assert _alpha(pixels, (75, 150)) == 0 + + controller.config.outside_view_transparency = 0.3 + pixels = _render(element) + assert _alpha(pixels, OUTSIDE) == pytest.approx(0.3 * 255, abs=1) + assert _alpha(pixels, INSIDE) == inside_alpha + + +def test_outside_view_is_hidden_immediately_when_invalidated(): + controller = _controller() + controller.config.outside_view_enabled = True + element = _element(controller) + _process(element) + assert _alpha(_render(element), OUTSIDE) > 0 + + controller.config.outside_view_margin_mm = 10 + assert _alpha(_render(element), OUTSIDE) == 0 + + controller.config.outside_view_margin_mm = 20 + controller.config.outside_view_enabled = False + assert _alpha(_render(element), OUTSIDE) == 0 + + controller.config.outside_view_enabled = True + controller.config.image_to_world = None + assert _alpha(_render(element), OUTSIDE) == 0 + + +def test_stale_job_does_not_commit_after_settings_change(): + controller = _controller() + element = _element(controller) + key = element._current_key() + assert key is not None + + controller.config.outside_view_enabled = True + element._process_and_update_cache(key) + + assert element._cached_surface is None + assert element._outside_cache is None + + +def test_draw_does_not_schedule_duplicate_jobs(): + controller = _controller() + element = _element(controller) + ctx = cairo.Context(cairo.ImageSurface(cairo.FORMAT_ARGB32, 10, 10)) + + with patch("rayforge.ui_gtk.canvas2d.elements.camera_image.GLib") as glib: + element.draw(ctx) + element.draw(ctx) + + assert glib.idle_add.call_count == 1 + + +def test_element_without_outside_support_keeps_its_clip(): + controller = _controller() + controller.config.outside_view_enabled = True + + element = _element(controller, supported=False) + + assert element.clip + assert element._outside_margin_request() is None From 4e3b90006c98dfcc373f0134f41af9c4173b3ed2 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Fri, 9 Oct 2026 11:31:35 +0200 Subject: [PATCH 015/104] feat: reload imported sources from disk in place Add ReloadCmd, which replaces the data of a SourceAsset with the current file on disk and updates every workpiece made from it as one undo step. Placement is carried over by re-running the import on the old and the new data and applying the user's transform on top, so content that did not change in the file stays where it was. Spec, layer, tabs and image modifiers are kept; elements are matched by source layer id. Add SourceWatcher, which reports sources whose file content changed and settled, and an auto_reload flag on SourceAsset. Refs #513 --- rayforge/core/source_asset.py | 4 + rayforge/core/workpiece.py | 8 + rayforge/doceditor/editor.py | 2 + rayforge/doceditor/reload_cmd.py | 562 +++++++++++++++++++++++++ rayforge/doceditor/source_watcher.py | 142 +++++++ tests/doceditor/test_reload_cmd.py | 499 ++++++++++++++++++++++ tests/doceditor/test_source_watcher.py | 183 ++++++++ 7 files changed, 1400 insertions(+) create mode 100644 rayforge/doceditor/reload_cmd.py create mode 100644 rayforge/doceditor/source_watcher.py create mode 100644 tests/doceditor/test_reload_cmd.py create mode 100644 tests/doceditor/test_source_watcher.py diff --git a/rayforge/core/source_asset.py b/rayforge/core/source_asset.py index 897daff1f..8f5b6f1c7 100644 --- a/rayforge/core/source_asset.py +++ b/rayforge/core/source_asset.py @@ -52,6 +52,7 @@ class SourceAsset(IAsset): height_px: int | None = None width_mm: float = 0.0 height_mm: float = 0.0 + auto_reload: bool = False _uid: str = field(init=False, default_factory=lambda: str(uuid.uuid4())) _name: str = field(init=False, repr=False) _hidden: bool = field(init=False, default=False) @@ -209,6 +210,7 @@ def to_dict(self) -> dict[str, Any]: "width_mm": self.width_mm, "height_mm": self.height_mm, "hidden": self._hidden, + "auto_reload": self.auto_reload, } result.update(self.extra) return result @@ -234,6 +236,7 @@ def from_dict(cls, data: dict[str, Any]) -> SourceAsset: "width_mm", "height_mm", "hidden", + "auto_reload", } extra = {k: v for k, v in data.items() if k not in known_keys} @@ -268,6 +271,7 @@ def from_dict(cls, data: dict[str, Any]) -> SourceAsset: height_px=data.get("height_px"), width_mm=data.get("width_mm", 0.0), height_mm=data.get("height_mm", 0.0), + auto_reload=data.get("auto_reload", False), ) if "uid" in data: instance._uid = data["uid"] diff --git a/rayforge/core/workpiece.py b/rayforge/core/workpiece.py index 2ecedf12d..8f25caff2 100644 --- a/rayforge/core/workpiece.py +++ b/rayforge/core/workpiece.py @@ -219,6 +219,14 @@ def source_segment(self, new_segment: SourceAssetSegment | None): # for triggering the pipeline to re-process the geometry. self.updated.send(self) + @property + def has_edited_boundaries(self) -> bool: + """ + True if the shape no longer comes straight from the source, e.g. + because the workpiece was split. + """ + return self._edited_boundaries is not None + @property def natural_size(self) -> tuple[float, float]: """ diff --git a/rayforge/doceditor/editor.py b/rayforge/doceditor/editor.py index 16e8c1b32..9c6e8d54e 100644 --- a/rayforge/doceditor/editor.py +++ b/rayforge/doceditor/editor.py @@ -26,6 +26,7 @@ from .group_cmd import GroupCmd from .layer_cmd import LayerCmd from .layout_cmd import LayoutCmd +from .reload_cmd import ReloadCmd from .split_cmd import SplitCmd from .step_cmd import StepCmd from .stock_cmd import StockCmd @@ -151,6 +152,7 @@ def __init__( self.group = GroupCmd(self, self.task_manager) self.layer = LayerCmd(self) self.layout = LayoutCmd(self, self.task_manager) + self.reload = ReloadCmd(self) self.split = SplitCmd(self) self.stock = StockCmd(self) self.step = StepCmd(self) diff --git a/rayforge/doceditor/reload_cmd.py b/rayforge/doceditor/reload_cmd.py new file mode 100644 index 000000000..125641a07 --- /dev/null +++ b/rayforge/doceditor/reload_cmd.py @@ -0,0 +1,562 @@ +from __future__ import annotations + +import json +import logging +from collections import defaultdict +from copy import deepcopy +from dataclasses import dataclass, field, replace +from gettext import gettext as _ +from gettext import ngettext +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from raygeo.geo import Geometry, Matrix + +from ..core.item import DocItem +from ..core.layer import Layer +from ..core.source_asset import SourceAsset +from ..core.source_asset_segment import SourceAssetSegment +from ..core.tab import Tab +from ..core.undo import Command, ListItemCommand +from ..core.vectorization_spec import ( + LayerImportMode, + LayerSource, + PassthroughSpec, + VectorizationSpec, +) +from ..core.workpiece import WorkPiece +from ..image import importer_registry +from ..image.base_importer import Importer +from ..image.structures import ImportResult, ParsingResult + +if TYPE_CHECKING: + from .editor import DocEditor + +logger = logging.getLogger(__name__) + +TAB_TOLERANCE_MM = 1.0 + +_SOURCE_DATA_FIELDS = ( + "original_data", + "base_render_data", + "thumbnail_data", + "renderer", + "metadata", + "width_px", + "height_px", + "width_mm", + "height_mm", +) + + +@dataclass +class ReloadReport: + """Summary of what a reload changed in the document.""" + + source_name: str + updated: int = 0 + added: int = 0 + removed: int = 0 + skipped: int = 0 + tabs_dropped: int = 0 + error: str | None = None + + def describe(self) -> str: + if self.error: + return _("Could not reload {name}: {error}").format( + name=self.source_name, error=self.error + ) + parts = [ + _("Reloaded {name}").format(name=self.source_name), + ] + if self.added: + parts.append( + ngettext( + "{n} new element added", + "{n} new elements added", + self.added, + ).format(n=self.added) + ) + if self.removed: + parts.append( + ngettext( + "{n} element no longer in the file was removed", + "{n} elements no longer in the file were removed", + self.removed, + ).format(n=self.removed) + ) + if self.skipped: + parts.append( + ngettext( + "{n} edited element was left unchanged", + "{n} edited elements were left unchanged", + self.skipped, + ).format(n=self.skipped) + ) + if self.tabs_dropped: + parts.append( + ngettext( + "{n} tab no longer fits and was removed", + "{n} tabs no longer fit and were removed", + self.tabs_dropped, + ).format(n=self.tabs_dropped) + ) + return "; ".join(parts) + + +class ReplaceSourceDataCommand(Command): + """Swaps the imported data of a SourceAsset, keeping its identity.""" + + def __init__( + self, + asset: SourceAsset, + new_values: dict[str, Any], + name: str | None = None, + ): + super().__init__(name) + self.asset = asset + self.new_values = new_values + self.old_values = { + key: getattr(asset, key) for key in _SOURCE_DATA_FIELDS + } + + def _apply(self, values: dict[str, Any]) -> None: + for key, value in values.items(): + setattr(self.asset, key, value) + self.asset.clear_base_image_cache() + self.asset.updated.send(self.asset) + + def execute(self) -> None: + self._apply(self.new_values) + + def undo(self) -> None: + self._apply(self.old_values) + + +@dataclass +class _WorkPieceState: + segment: SourceAssetSegment | None + matrix: Matrix + natural_size: tuple[float, float] + tabs: list[Tab] + + +class ReloadWorkPieceCommand(Command): + """Replaces the source-derived state of a WorkPiece.""" + + def __init__( + self, + workpiece: WorkPiece, + new_state: _WorkPieceState, + name: str | None = None, + ): + super().__init__(name) + self.workpiece = workpiece + self.new_state = new_state + self.old_state = _WorkPieceState( + segment=workpiece.source_segment, + matrix=workpiece.matrix.copy(), + natural_size=workpiece.natural_size, + tabs=list(workpiece.tabs), + ) + + def _apply(self, state: _WorkPieceState) -> None: + wp = self.workpiece + wp.natural_width_mm, wp.natural_height_mm = state.natural_size + wp.tabs = list(state.tabs) + wp.matrix = state.matrix + segment_changed = wp.source_segment != state.segment + wp.source_segment = state.segment + if not segment_changed: + wp.clear_render_cache() + wp.updated.send(wp) + + def execute(self) -> None: + self._apply(self.new_state) + + def undo(self) -> None: + self._apply(self.old_state) + + +@dataclass +class _Fresh: + """A workpiece produced by running the import pipeline once.""" + + workpiece: WorkPiece + wrapper: Layer | None + + +@dataclass +class _Plan: + source_values: dict[str, Any] | None = None + updates: list[tuple[WorkPiece, _WorkPieceState]] = field( + default_factory=list + ) + removals: list[WorkPiece] = field(default_factory=list) + additions: list[tuple[DocItem, DocItem]] = field(default_factory=list) + + +def _spec_key(spec: VectorizationSpec) -> str: + return json.dumps(spec.to_dict(), sort_keys=True, default=str) + + +def _source_of(result: ImportResult) -> SourceAsset: + assert result.payload is not None and result.payload.source is not None + return result.payload.source + + +def _fresh_items(result: ImportResult) -> dict[str | None, _Fresh]: + items: dict[str | None, _Fresh] = {} + if not result.payload: + return items + for item in result.payload.items: + if isinstance(item, Layer): + for child in item.get_content_items(): + if isinstance(child, WorkPiece) and child.source_segment: + items[child.source_segment.layer_id] = _Fresh(child, item) + elif isinstance(item, WorkPiece) and item.source_segment: + items[item.source_segment.layer_id] = _Fresh(item, None) + return items + + +def _page_bottom_mm(parse_result: ParsingResult) -> float: + ref = ( + parse_result.untrimmed_document_bounds or parse_result.document_bounds + ) + return (ref[1] + ref[3]) * parse_result.native_unit_to_mm + + +def _anchor_shift(old: ImportResult, new: ImportResult) -> Matrix: + """ + Fresh imports of Y-down formats measure positions from the bottom of + the page. This shift re-anchors the new import to the top of the old + page, so content keeps its place when the page height changes. + """ + old_parse, new_parse = old.parse_result, new.parse_result + if not old_parse or not new_parse: + return Matrix.identity() + if not (old_parse.is_y_down and new_parse.is_y_down): + return Matrix.identity() + shift = _page_bottom_mm(old_parse) - _page_bottom_mm(new_parse) + return Matrix.translation(0.0, shift) + + +def _local_geometry(boundaries: Geometry, matrix: Matrix) -> Geometry: + geo = boundaries.copy() + geo.transform(matrix) + return geo + + +class ReloadCmd: + """Reloads imported sources from their files on disk.""" + + def __init__(self, editor: DocEditor): + self._editor = editor + + def workpieces_for(self, asset: SourceAsset) -> list[WorkPiece]: + return [ + wp + for wp in self._editor.doc.all_workpieces + if wp.source_segment + and wp.source_segment.source_asset_uid == asset.uid + ] + + def reload_source( + self, asset: SourceAsset, data: bytes | None = None + ) -> ReloadReport: + """ + Replaces the data of a source with the given bytes (or the current + file on disk) and updates every workpiece made from it in place, + as a single undoable step. + """ + report = ReloadReport(source_name=asset.name) + try: + if data is None: + data = asset.source_file.read_bytes() + plan = self._plan(asset, data, report) + except Exception as e: + logger.exception(f"Reload of {asset.source_file} failed") + report.error = str(e) + self._editor.notification_requested.send( + self, message=report.describe() + ) + return report + + self._apply(asset, plan) + self._editor.notification_requested.send( + self, message=report.describe() + ) + return report + + def _importer_class(self, asset: SourceAsset) -> type[Importer]: + name = asset.metadata.get("_importer_class") + importer_cls = importer_registry.get_by_name(name) if name else None + if importer_cls is None: + raise ValueError(_("the file type is not known")) + return importer_cls + + @staticmethod + def _run( + importer_cls: type[Importer], + data: bytes, + path: Path, + spec: VectorizationSpec | None, + ) -> ImportResult: + result = importer_cls(data, path).get_doc_items(spec) + if not result or not result.payload or not result.payload.source: + errors = result.errors if result else [] + raise ValueError( + "; ".join(errors) or _("the file could not be read") + ) + return result + + @staticmethod + def _layer_ids( + importer_cls: type[Importer], + data: bytes, + path: Path, + spec: PassthroughSpec, + ) -> list[str]: + manifest = importer_cls(data, path).scan() + if spec.layer_source == LayerSource.COLORS: + layers = manifest.color_layers + else: + layers = manifest.layers + return [layer.id for layer in layers] + + def _spec_for_new_data( + self, + importer_cls: type[Importer], + asset: SourceAsset, + data: bytes, + spec: VectorizationSpec, + ) -> VectorizationSpec: + """ + If all layers of the old file were imported, the new file's layers + are all imported too. A chosen subset stays as it is. + """ + if not isinstance(spec, PassthroughSpec) or not spec.active_layer_ids: + return spec + path = asset.source_file + old_ids = self._layer_ids( + importer_cls, asset.original_data, path, spec + ) + if not set(old_ids) <= set(spec.active_layer_ids): + return spec + new_ids = self._layer_ids(importer_cls, data, path, spec) + return replace(spec, active_layer_ids=new_ids) + + def _plan( + self, asset: SourceAsset, data: bytes, report: ReloadReport + ) -> _Plan: + importer_cls = self._importer_class(asset) + plan = _Plan() + groups: dict[str, list[WorkPiece]] = defaultdict(list) + for wp in self.workpieces_for(asset): + if wp.has_edited_boundaries: + report.skipped += 1 + elif wp.source_segment: + key = _spec_key(wp.source_segment.vectorization_spec) + groups[key].append(wp) + + new_source = None + for workpieces in groups.values(): + result = self._plan_group( + importer_cls, asset, data, workpieces, plan, report + ) + new_source = new_source or _source_of(result) + + if new_source is None: + result = self._run(importer_cls, data, asset.source_file, None) + new_source = _source_of(result) + + metadata = dict(new_source.metadata) + for key in ("_importer_class", "_importer_mime"): + if key in asset.metadata: + metadata.setdefault(key, asset.metadata[key]) + plan.source_values = { + key: getattr(new_source, key) for key in _SOURCE_DATA_FIELDS + } + plan.source_values["metadata"] = metadata + return plan + + def _plan_group( + self, + importer_cls: type[Importer], + asset: SourceAsset, + data: bytes, + workpieces: list[WorkPiece], + plan: _Plan, + report: ReloadReport, + ) -> ImportResult: + segment = workpieces[0].source_segment + assert segment is not None + spec = segment.vectorization_spec + path = asset.source_file + new_spec = self._spec_for_new_data(importer_cls, asset, data, spec) + old_result = self._run(importer_cls, asset.original_data, path, spec) + new_result = self._run(importer_cls, data, path, new_spec) + if not _fresh_items(new_result): + raise ValueError(_("the file contains nothing to import")) + + old_items = _fresh_items(old_result) + new_items = _fresh_items(new_result) + anchor = _anchor_shift(old_result, new_result) + + reference: tuple[WorkPiece, Matrix] | None = None + for wp in workpieces: + assert wp.source_segment is not None + layer_id = wp.source_segment.layer_id + old = old_items.get(layer_id) + if old is None: + report.skipped += 1 + continue + delta = wp.matrix @ old.workpiece.matrix.invert() + new = new_items.get(layer_id) + if new is None: + plan.removals.append(wp) + report.removed += 1 + continue + reference = reference or (wp, delta) + state = self._new_state( + wp, new.workpiece, delta @ anchor, asset, new_spec, report + ) + plan.updates.append((wp, state)) + report.updated += 1 + + if reference is not None: + for layer_id, new in new_items.items(): + if layer_id in old_items: + continue + self._plan_addition( + new, reference, anchor, asset, new_spec, plan + ) + report.added += 1 + return new_result + + @staticmethod + def _adopt_segment( + fresh: WorkPiece, + asset: SourceAsset, + spec: VectorizationSpec, + previous: SourceAssetSegment | None, + ) -> SourceAssetSegment: + assert fresh.source_segment is not None + modifiers = previous.image_modifier_chain if previous else [] + return replace( + fresh.source_segment, + source_asset_uid=asset.uid, + vectorization_spec=spec, + image_modifier_chain=deepcopy(modifiers), + ) + + def _new_state( + self, + wp: WorkPiece, + fresh: WorkPiece, + delta: Matrix, + asset: SourceAsset, + spec: VectorizationSpec, + report: ReloadReport, + ) -> _WorkPieceState: + matrix = delta @ fresh.matrix + segment = self._adopt_segment(fresh, asset, spec, wp.source_segment) + tabs, dropped = self._remap_tabs(wp, fresh, matrix) + report.tabs_dropped += dropped + return _WorkPieceState( + segment=segment, + matrix=matrix, + natural_size=fresh.natural_size, + tabs=tabs, + ) + + @staticmethod + def _remap_tabs( + wp: WorkPiece, fresh: WorkPiece, matrix: Matrix + ) -> tuple[list[Tab], int]: + """ + Moves each tab to the closest point of the new outline, measured + in the workpiece's parent space. Tabs whose outline moved away are + dropped. + """ + if not wp.tabs: + return [], 0 + old_boundaries = wp.boundaries + new_boundaries = fresh.boundaries + if old_boundaries is None or new_boundaries is None: + return [], len(wp.tabs) + old_geo = _local_geometry(old_boundaries, wp.matrix) + new_geo = _local_geometry(new_boundaries, matrix) + kept = [] + for tab in wp.tabs: + point = old_geo.get_point_at(tab.segment_index, tab.pos) + if point is None: + continue + closest = new_geo.find_closest_point(point[0], point[1]) + if closest is None: + continue + index, t, new_point = closest + distance = ( + (new_point[0] - point[0]) ** 2 + (new_point[1] - point[1]) ** 2 + ) ** 0.5 + if distance <= TAB_TOLERANCE_MM: + kept.append( + replace( + tab, segment_index=index, pos=min(1.0, max(0.0, t)) + ) + ) + return kept, len(wp.tabs) - len(kept) + + def _plan_addition( + self, + new: _Fresh, + reference: tuple[WorkPiece, Matrix], + anchor: Matrix, + asset: SourceAsset, + spec: VectorizationSpec, + plan: _Plan, + ) -> None: + ref_wp, delta = reference + wp = new.workpiece + wp.matrix = delta @ anchor @ wp.matrix + wp.source_segment = self._adopt_segment(wp, asset, spec, None) + mode = LayerImportMode.NEW_LAYERS + if isinstance(spec, PassthroughSpec): + mode = spec.layer_import_mode + if new.wrapper is not None and mode == LayerImportMode.NEW_LAYERS: + plan.additions.append((self._editor.doc, new.wrapper)) + elif ref_wp.parent is not None: + if new.wrapper is not None: + new.wrapper.remove_child(wp) + plan.additions.append((ref_wp.parent, wp)) + + def _apply(self, asset: SourceAsset, plan: _Plan) -> None: + name = _("Reload {name}").format(name=asset.name) + assert plan.source_values is not None + with self._editor.history_manager.transaction(name) as t: + t.execute(ReplaceSourceDataCommand(asset, plan.source_values)) + for wp, state in plan.updates: + t.execute(ReloadWorkPieceCommand(wp, state)) + for wp in plan.removals: + t.execute( + ListItemCommand( + owner_obj=wp.parent, + item=wp, + undo_command="add_child", + redo_command="remove_child", + ) + ) + for owner, item in plan.additions: + t.execute( + ListItemCommand( + owner_obj=owner, + item=item, + undo_command="remove_child", + redo_command="add_child", + ) + ) + new_layers = [ + item for _owner, item in plan.additions if isinstance(item, Layer) + ] + if new_layers: + self._editor.step.add_default_steps_for_layers(new_layers) diff --git a/rayforge/doceditor/source_watcher.py b/rayforge/doceditor/source_watcher.py new file mode 100644 index 000000000..533ae5353 --- /dev/null +++ b/rayforge/doceditor/source_watcher.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +import hashlib +import logging +from dataclasses import dataclass, field +from pathlib import Path +from typing import TYPE_CHECKING + +from ..core.source_asset import SourceAsset + +if TYPE_CHECKING: + from .editor import DocEditor + +logger = logging.getLogger(__name__) + +FileSignature = tuple[int, int] + + +@dataclass +class _WatchState: + signature: FileSignature + pending: bool = True + seen_hashes: set[str] = field(default_factory=set) + + +def _digest(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def _signature(path: Path) -> FileSignature | None: + try: + stat = path.stat() + except OSError: + return None + return (stat.st_mtime_ns, stat.st_size) + + +def _read(path: Path) -> bytes | None: + try: + return path.read_bytes() + except OSError: + return None + + +class SourceWatcher: + """ + Detects imported files that changed on disk. + + The watcher is driven by calling :meth:`poll` periodically. A file + only counts as changed once its modification time and size were the + same in two consecutive polls, so files that are still being written + (or saved through a temporary file and a rename) are not reported + half-way. The content is then compared with the data embedded in the + document, so touching a file or saving it unchanged is ignored. + + Every changed content is reported once; :meth:`acknowledge` marks the + current content of a file as handled without reporting it. + """ + + def __init__(self, editor: DocEditor): + self._editor = editor + self._states: dict[str, _WatchState] = {} + + def watched_assets(self) -> list[SourceAsset]: + """Returns the sources whose files are worth watching.""" + doc = self._editor.doc + used = { + wp.source_segment.source_asset_uid + for wp in doc.all_workpieces + if wp.source_segment + } + return [ + asset + for uid, asset in doc.source_assets.items() + if uid in used + and asset.source_file.is_absolute() + and asset.metadata.get("_importer_class") + ] + + def poll(self) -> list[SourceAsset]: + """ + Checks all watched files and returns the sources whose file + content changed and settled since the last report. + """ + changed = [] + for asset in self.watched_assets(): + signature = _signature(asset.source_file) + if signature is None: + self._states.pop(asset.uid, None) + continue + state = self._states.get(asset.uid) + if state is None: + self._states[asset.uid] = _WatchState(signature) + continue + if signature != state.signature: + state.signature = signature + state.pending = True + continue + if state.pending: + state.pending = False + if self._is_new_content(asset, state): + changed.append(asset) + return changed + + def check_now(self) -> list[SourceAsset]: + """ + Compares all watched files with the document right away, without + waiting for the files to settle. Meant for opening a project. + """ + changed = [] + for asset in self.watched_assets(): + signature = _signature(asset.source_file) + if signature is None: + continue + state = self._states.setdefault(asset.uid, _WatchState(signature)) + state.signature = signature + state.pending = False + if self._is_new_content(asset, state): + changed.append(asset) + return changed + + def acknowledge(self, asset: SourceAsset) -> None: + """Marks the current file content of a source as handled.""" + signature = _signature(asset.source_file) + data = _read(asset.source_file) + if signature is None or data is None: + return + state = self._states.setdefault(asset.uid, _WatchState(signature)) + state.seen_hashes.add(_digest(data)) + + def _is_new_content(self, asset: SourceAsset, state: _WatchState) -> bool: + data = _read(asset.source_file) + if data is None: + return False + digest = _digest(data) + if digest in state.seen_hashes: + return False + if digest == _digest(asset.original_data): + return False + state.seen_hashes.add(digest) + logger.info(f"Source file changed on disk: {asset.source_file}") + return True diff --git a/tests/doceditor/test_reload_cmd.py b/tests/doceditor/test_reload_cmd.py new file mode 100644 index 000000000..8a7f8c56d --- /dev/null +++ b/tests/doceditor/test_reload_cmd.py @@ -0,0 +1,499 @@ +import math +from pathlib import Path +from unittest.mock import MagicMock + +import ezdxf +import pytest +import pyvips +from raygeo.geo import Geometry, Matrix + +from rayforge.core.doc import Doc +from rayforge.core.layer import Layer +from rayforge.core.source_asset import SourceAsset +from rayforge.core.tab import Tab +from rayforge.core.vectorization_spec import ( + LayerImportMode, + LayerSource, + PassthroughSpec, + TraceSpec, +) +from rayforge.core.workpiece import WorkPiece +from rayforge.doceditor.editor import DocEditor +from rayforge.image import importer_registry +from rayforge.shared.tasker.manager import TaskManager + +PAGE_80 = ( + '{body}' +) +PAGE_120 = ( + '{body}' +) +RED_RECT = ( + '' +) +BLUE_CIRCLE = '' +GREEN_SQUARE = ( + '' +) +WIDE_RED_RECT = ( + '' +) + + +@pytest.fixture +def editor(context_initializer): + task_manager = MagicMock(spec=TaskManager) + editor = DocEditor(task_manager, context_initializer, Doc()) + yield editor + editor.cleanup() + + +def _write(path: Path, template: str, *parts: str) -> Path: + path.write_text(template.format(body="".join(parts))) + return path + + +def _import(editor: DocEditor, path: Path, spec=None) -> SourceAsset: + importer_cls = importer_registry.get_for_file(path) + assert importer_cls is not None + importer = importer_cls(path.read_bytes(), path) + result = importer.get_doc_items(spec) + assert result is not None and result.payload is not None + editor.file._finalize_import_on_main_thread( + result.payload, path, None, spec + ) + assert result.payload.source is not None + return result.payload.source + + +def _flatten_spec() -> PassthroughSpec: + return PassthroughSpec(layer_import_mode=LayerImportMode.FLATTEN) + + +def _color_spec(active=None) -> PassthroughSpec: + return PassthroughSpec( + active_layer_ids=active, + layer_import_mode=LayerImportMode.NEW_LAYERS, + layer_source=LayerSource.COLORS, + ) + + +def _workpieces_of(doc: Doc, asset: SourceAsset) -> list[WorkPiece]: + return [ + wp + for wp in doc.all_workpieces + if wp.source_segment + and wp.source_segment.source_asset_uid == asset.uid + ] + + +def _by_layer_id(doc: Doc, asset: SourceAsset) -> dict: + return { + wp.source_segment.layer_id: wp + for wp in _workpieces_of(doc, asset) + if wp.source_segment + } + + +def _sample_points(geo: Geometry) -> list[tuple[float, float]]: + points = [] + for index in range(len(list(geo.iter_commands()))): + for t in (0.0, 0.5, 1.0): + point = geo.get_point_at(index, t) + if point is not None: + points.append((point[0], point[1])) + return points + + +def _distance_to(geo: Geometry, x: float, y: float) -> float: + closest = geo.find_closest_point(x, y) + assert closest is not None + point = closest[2] + return math.hypot(point[0] - x, point[1] - y) + + +def _assert_contained(old: Geometry, new: Geometry, tol: float = 1e-3): + points = _sample_points(old) + assert points + for x, y in points: + assert _distance_to(new, x, y) < tol, (x, y) + + +def _world_bbox(wp: WorkPiece) -> tuple[float, float, float, float]: + geo = wp.get_world_geometry() + assert geo is not None + return geo.rect() + + +def _transform(wp: WorkPiece, delta: Matrix): + wp.matrix = delta @ wp.matrix + + +class TestReloadPlacement: + def test_unchanged_content_stays_put_after_user_transform( + self, editor, tmp_path + ): + path = _write(tmp_path / "a.svg", PAGE_80, RED_RECT, BLUE_CIRCLE) + asset = _import(editor, path, _flatten_spec()) + (wp,) = _workpieces_of(editor.doc, asset) + _transform( + wp, + Matrix.translation(13, -4) + @ Matrix.rotation(30) + @ Matrix.scale(1.5, 1.5), + ) + old_world = wp.get_world_geometry() + assert old_world is not None + + _write(path, PAGE_80, RED_RECT, BLUE_CIRCLE, GREEN_SQUARE) + report = editor.reload.reload_source(asset) + + assert report.updated == 1 + new_world = wp.get_world_geometry() + assert new_world is not None + _assert_contained(old_world, new_world) + assert len(_sample_points(new_world)) > len(_sample_points(old_world)) + + def test_taller_page_does_not_move_content(self, editor, tmp_path): + path = _write(tmp_path / "a.svg", PAGE_80, RED_RECT, BLUE_CIRCLE) + asset = _import(editor, path, _flatten_spec()) + (wp,) = _workpieces_of(editor.doc, asset) + old_world = wp.get_world_geometry() + assert old_world is not None + + _write(path, PAGE_120, RED_RECT, BLUE_CIRCLE, GREEN_SQUARE) + editor.reload.reload_source(asset) + + new_world = wp.get_world_geometry() + assert new_world is not None + _assert_contained(old_world, new_world) + + def test_scale_factor_is_kept(self, editor, tmp_path): + path = _write(tmp_path / "a.svg", PAGE_80, RED_RECT) + asset = _import(editor, path, _flatten_spec()) + (wp,) = _workpieces_of(editor.doc, asset) + _transform(wp, Matrix.scale(2.0, 2.0)) + x0, y0, x1, y1 = _world_bbox(wp) + assert x1 - x0 == pytest.approx(40.0, abs=0.1) + + _write(path, PAGE_80, WIDE_RED_RECT) + editor.reload.reload_source(asset) + + nx0, ny0, nx1, ny1 = _world_bbox(wp) + assert nx1 - nx0 == pytest.approx(60.0, abs=0.1) + assert ny1 - ny0 == pytest.approx(y1 - y0, abs=0.1) + assert nx0 == pytest.approx(x0, abs=0.1) + assert ny1 == pytest.approx(y1, abs=0.1) + + def test_copies_keep_their_own_placement(self, editor, tmp_path): + path = _write(tmp_path / "a.svg", PAGE_80, RED_RECT) + asset = _import(editor, path, _flatten_spec()) + (original,) = _workpieces_of(editor.doc, asset) + copy = WorkPiece.from_dict(original.to_dict()) + copy.uid = "copy-uid" + assert original.parent is not None + original.parent.add_child(copy) + _transform(copy, Matrix.translation(50, 25)) + old_original = original.get_world_geometry() + old_copy = copy.get_world_geometry() + assert old_original is not None and old_copy is not None + + _write(path, PAGE_80, RED_RECT, GREEN_SQUARE) + report = editor.reload.reload_source(asset) + + assert report.updated == 2 + new_original = original.get_world_geometry() + new_copy = copy.get_world_geometry() + assert new_original is not None and new_copy is not None + _assert_contained(old_original, new_original) + _assert_contained(old_copy, new_copy) + + +class TestReloadKeepsSettings: + def test_identity_layer_and_spec_are_kept(self, editor, tmp_path): + path = _write(tmp_path / "a.svg", PAGE_80, RED_RECT) + spec = _flatten_spec() + asset = _import(editor, path, spec) + (wp,) = _workpieces_of(editor.doc, asset) + wp.name = "My part" + wp.tabs_enabled = False + assert wp.source_segment is not None + wp.source_segment.image_modifier_chain = [{"name": "Invert"}] + uid, parent = wp.uid, wp.parent + old_width = wp.natural_width_mm + old_spec = wp.source_segment.vectorization_spec + + _write(path, PAGE_80, WIDE_RED_RECT) + editor.reload.reload_source(asset) + + assert wp.uid == uid + assert wp.parent is parent + assert wp.name == "My part" + assert wp.tabs_enabled is False + assert wp.source_segment is not None + assert wp.source_segment.source_asset_uid == asset.uid + assert wp.source_segment.image_modifier_chain == [{"name": "Invert"}] + assert ( + wp.source_segment.vectorization_spec.to_dict() + == old_spec.to_dict() + ) + assert wp.natural_width_mm > old_width + 9 + + def test_source_asset_takes_new_data(self, editor, tmp_path): + path = _write(tmp_path / "a.svg", PAGE_80, RED_RECT) + asset = _import(editor, path, _flatten_spec()) + uid = asset.uid + + _write(path, PAGE_80, WIDE_RED_RECT) + editor.reload.reload_source(asset) + + assert asset.uid == uid + assert asset.original_data == path.read_bytes() + assert editor.doc.get_source_asset_by_uid(uid) is asset + + def test_tab_on_unchanged_outline_is_kept(self, editor, tmp_path): + path = _write(tmp_path / "a.svg", PAGE_80, RED_RECT, BLUE_CIRCLE) + asset = _import(editor, path, _flatten_spec()) + (wp,) = _workpieces_of(editor.doc, asset) + _transform(wp, Matrix.translation(5, 5)) + assert wp.boundaries is not None + closest = wp.boundaries.find_closest_point(0.0, 1.0) + assert closest is not None + wp.tabs = [Tab(width=3.0, segment_index=closest[0], pos=closest[1])] + tab_point = _tab_world_point(wp, wp.tabs[0]) + + _write(path, PAGE_80, RED_RECT, BLUE_CIRCLE, GREEN_SQUARE) + report = editor.reload.reload_source(asset) + + assert report.tabs_dropped == 0 + assert len(wp.tabs) == 1 + assert wp.tabs[0].width == 3.0 + new_point = _tab_world_point(wp, wp.tabs[0]) + assert math.dist(tab_point, new_point) < 1e-3 + + def test_tab_on_moved_outline_is_dropped(self, editor, tmp_path): + path = _write(tmp_path / "a.svg", PAGE_80, RED_RECT, BLUE_CIRCLE) + asset = _import(editor, path, _flatten_spec()) + (wp,) = _workpieces_of(editor.doc, asset) + assert wp.boundaries is not None + closest = wp.boundaries.find_closest_point(0.0, 1.0) + assert closest is not None + wp.tabs = [Tab(width=3.0, segment_index=closest[0], pos=closest[1])] + + moved = RED_RECT.replace('x="10"', 'x="70"').replace( + 'y="10"', 'y="60"' + ) + _write(path, PAGE_80, moved, BLUE_CIRCLE) + report = editor.reload.reload_source(asset) + + assert report.tabs_dropped == 1 + assert wp.tabs == [] + + def test_split_pieces_are_left_alone(self, editor, tmp_path): + path = _write(tmp_path / "a.svg", PAGE_80, RED_RECT, BLUE_CIRCLE) + asset = _import(editor, path, _flatten_spec()) + (wp,) = _workpieces_of(editor.doc, asset) + boundaries = wp.boundaries + assert boundaries is not None + pieces = wp.apply_split(boundaries.split_into_components()) + layer = wp.parent + assert layer is not None + layer.remove_child(wp) + for piece in pieces: + layer.add_child(piece) + matrices = [piece.matrix.copy() for piece in pieces] + + _write(path, PAGE_80, WIDE_RED_RECT, BLUE_CIRCLE) + report = editor.reload.reload_source(asset) + + assert report.updated == 0 + assert report.skipped == len(pieces) + assert [p.matrix for p in pieces] == matrices + + +def _tab_world_point(wp: WorkPiece, tab: Tab) -> tuple[float, float]: + boundaries = wp.boundaries + assert boundaries is not None + point = boundaries.get_point_at(tab.segment_index, tab.pos) + assert point is not None + return wp.get_world_transform().transform_point((point[0], point[1])) + + +class TestReloadLayers: + def test_matches_elements_by_color(self, editor, tmp_path): + path = _write(tmp_path / "a.svg", PAGE_80, RED_RECT, BLUE_CIRCLE) + asset = _import(editor, path, _color_spec()) + by_id = _by_layer_id(editor.doc, asset) + red = by_id["#ff0000"] + blue = by_id["#0000ff"] + _transform(red, Matrix.translation(7, 3)) + _transform(blue, Matrix.translation(-20, 11)) + old_blue = blue.get_world_geometry() + assert old_blue is not None + + _write(path, PAGE_80, WIDE_RED_RECT, BLUE_CIRCLE) + report = editor.reload.reload_source(asset) + + assert report.updated == 2 + new_blue = blue.get_world_geometry() + assert new_blue is not None + _assert_contained(old_blue, new_blue) + rx0, _ry0, rx1, _ry1 = _world_bbox(red) + assert rx1 - rx0 == pytest.approx(30.0, abs=0.1) + + def test_element_gone_from_file_is_removed(self, editor, tmp_path): + path = _write(tmp_path / "a.svg", PAGE_80, RED_RECT, BLUE_CIRCLE) + asset = _import(editor, path, _color_spec()) + + _write(path, PAGE_80, RED_RECT) + report = editor.reload.reload_source(asset) + + assert report.removed == 1 + assert set(_by_layer_id(editor.doc, asset)) == {"#ff0000"} + + def test_new_color_is_added_when_all_were_imported(self, editor, tmp_path): + path = _write(tmp_path / "a.svg", PAGE_80, RED_RECT, BLUE_CIRCLE) + asset = _import(editor, path, _color_spec()) + by_id = _by_layer_id(editor.doc, asset) + _transform(by_id["#ff0000"], Matrix.translation(7, 3)) + _transform(by_id["#0000ff"], Matrix.translation(7, 3)) + layer_count = len(editor.doc.layers) + + _write(path, PAGE_80, RED_RECT, BLUE_CIRCLE, GREEN_SQUARE) + report = editor.reload.reload_source(asset) + + assert report.added == 1 + green = _by_layer_id(editor.doc, asset)["#00ff00"] + assert isinstance(green.parent, Layer) + assert len(editor.doc.layers) == layer_count + 1 + gx0, _gy0, _gx1, gy1 = _world_bbox(green) + rx0, _ry0, _rx1, ry1 = _world_bbox(by_id["#ff0000"]) + assert gx0 == pytest.approx(rx0 - 8.0, abs=0.1) + assert gy1 == pytest.approx(ry1 - 50.0, abs=0.1) + + def test_new_color_is_not_added_to_a_chosen_subset(self, editor, tmp_path): + path = _write(tmp_path / "a.svg", PAGE_80, RED_RECT, BLUE_CIRCLE) + asset = _import(editor, path, _color_spec(active=["#ff0000"])) + + _write(path, PAGE_80, RED_RECT, BLUE_CIRCLE, GREEN_SQUARE) + report = editor.reload.reload_source(asset) + + assert report.added == 0 + assert set(_by_layer_id(editor.doc, asset)) == {"#ff0000"} + + +class TestReloadUndo: + def test_reload_is_one_undo_step(self, editor, tmp_path): + path = _write(tmp_path / "a.svg", PAGE_80, RED_RECT, BLUE_CIRCLE) + asset = _import(editor, path, _color_spec()) + old_data = asset.original_data + by_id = _by_layer_id(editor.doc, asset) + red = by_id["#ff0000"] + old_matrix = red.matrix.copy() + old_width = red.natural_width_mm + + _write(path, PAGE_80, WIDE_RED_RECT, GREEN_SQUARE) + editor.reload.reload_source(asset) + assert set(_by_layer_id(editor.doc, asset)) == { + "#ff0000", + "#00ff00", + } + + editor.history_manager.undo() + + assert asset.original_data == old_data + assert red.matrix == old_matrix + assert red.natural_width_mm == pytest.approx(old_width) + assert set(_by_layer_id(editor.doc, asset)) == { + "#ff0000", + "#0000ff", + } + + editor.history_manager.redo() + + assert asset.original_data == path.read_bytes() + rx0, _ry0, rx1, _ry1 = _world_bbox(red) + assert rx1 - rx0 == pytest.approx(30.0, abs=0.1) + + def test_unreadable_data_changes_nothing(self, editor, tmp_path): + path = _write(tmp_path / "a.svg", PAGE_80, RED_RECT) + asset = _import(editor, path, _flatten_spec()) + (wp,) = _workpieces_of(editor.doc, asset) + old_data = asset.original_data + old_matrix = wp.matrix.copy() + can_undo = editor.history_manager.can_undo() + undo_depth = len(editor.history_manager.undo_stack) + + report = editor.reload.reload_source(asset, b"this is not an svg") + + assert report.error + assert asset.original_data == old_data + assert wp.matrix == old_matrix + assert editor.history_manager.can_undo() == can_undo + assert len(editor.history_manager.undo_stack) == undo_depth + + +def _write_png( + path: Path, squares: list[tuple[int, int, int]], height: int = 160 +) -> Path: + image = pyvips.Image.black(200, height).invert() + for x, y, size in squares: + image = image.draw_rect(0, x, y, size, size, fill=True) + image.cast("uchar").pngsave(str(path)) + return path + + +class TestReloadBitmap: + def test_trace_settings_are_kept_and_content_stays_put( + self, editor, tmp_path + ): + path = _write_png(tmp_path / "a.png", [(20, 20, 40)]) + spec = TraceSpec(threshold=0.42, auto_threshold=False) + asset = _import(editor, path, spec) + (wp,) = _workpieces_of(editor.doc, asset) + _transform(wp, Matrix.translation(10, 10) @ Matrix.rotation(15)) + old_world = wp.get_world_geometry() + assert old_world is not None + + _write_png(path, [(20, 20, 40), (120, 180, 30)], height=240) + report = editor.reload.reload_source(asset) + + assert report.updated == 1 + assert wp.source_segment is not None + assert wp.source_segment.vectorization_spec.to_dict() == ( + spec.to_dict() + ) + new_world = wp.get_world_geometry() + assert new_world is not None + _assert_contained(old_world, new_world, tol=0.05) + assert asset.original_data == path.read_bytes() + + +def _write_dxf(path: Path, extra_circle: bool) -> Path: + drawing = ezdxf.new() # type: ignore + msp = drawing.modelspace() + msp.add_lwpolyline([(10, 10), (30, 10), (30, 20), (10, 20)], close=True) + if extra_circle: + msp.add_circle((80, 90), radius=5) + drawing.saveas(path) + return path + + +class TestReloadDxf: + def test_unchanged_content_stays_put(self, editor, tmp_path): + path = _write_dxf(tmp_path / "a.dxf", extra_circle=False) + asset = _import(editor, path, _flatten_spec()) + (wp,) = _workpieces_of(editor.doc, asset) + _transform(wp, Matrix.translation(4, 9) @ Matrix.rotation(-20)) + old_world = wp.get_world_geometry() + assert old_world is not None + + _write_dxf(path, extra_circle=True) + report = editor.reload.reload_source(asset) + + assert report.updated == 1 + new_world = wp.get_world_geometry() + assert new_world is not None + _assert_contained(old_world, new_world) diff --git a/tests/doceditor/test_source_watcher.py b/tests/doceditor/test_source_watcher.py new file mode 100644 index 000000000..150db9b8e --- /dev/null +++ b/tests/doceditor/test_source_watcher.py @@ -0,0 +1,183 @@ +import os +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from rayforge.core.doc import Doc +from rayforge.core.source_asset import SourceAsset +from rayforge.core.vectorization_spec import ( + LayerImportMode, + PassthroughSpec, +) +from rayforge.doceditor.editor import DocEditor +from rayforge.doceditor.source_watcher import SourceWatcher +from rayforge.image import importer_registry +from rayforge.shared.tasker.manager import TaskManager + +SVG = ( + '' +) + + +@pytest.fixture +def editor(context_initializer): + task_manager = MagicMock(spec=TaskManager) + editor = DocEditor(task_manager, context_initializer, Doc()) + yield editor + editor.cleanup() + + +def _write(path: Path, width: int, mtime_ns: int) -> None: + path.write_text(SVG.format(w=width)) + os.utime(path, ns=(mtime_ns, mtime_ns)) + + +def _import(editor: DocEditor, path: Path) -> SourceAsset: + importer_cls = importer_registry.get_for_file(path) + assert importer_cls is not None + spec = PassthroughSpec(layer_import_mode=LayerImportMode.FLATTEN) + result = importer_cls(path.read_bytes(), path).get_doc_items(spec) + assert result is not None and result.payload is not None + editor.file._finalize_import_on_main_thread( + result.payload, path, None, spec + ) + assert result.payload.source is not None + return result.payload.source + + +@pytest.fixture +def imported(editor, tmp_path): + path = tmp_path / "part.svg" + _write(path, 20, 1_000_000_000) + asset = _import(editor, path) + watcher = SourceWatcher(editor) + assert watcher.poll() == [] + assert watcher.poll() == [] + return watcher, asset, path + + +class TestSourceWatcher: + def test_unchanged_file_is_not_reported(self, imported): + watcher, _asset, _path = imported + assert watcher.poll() == [] + + def test_change_is_reported_once_the_file_is_stable(self, imported): + watcher, asset, path = imported + _write(path, 30, 2_000_000_000) + + assert watcher.poll() == [] + assert watcher.poll() == [asset] + assert watcher.poll() == [] + + def test_file_still_being_written_is_not_reported(self, imported): + watcher, _asset, path = imported + _write(path, 30, 2_000_000_000) + assert watcher.poll() == [] + _write(path, 35, 3_000_000_000) + assert watcher.poll() == [] + + def test_touch_without_new_content_is_not_reported(self, imported): + watcher, _asset, path = imported + os.utime(path, ns=(5_000_000_000, 5_000_000_000)) + + assert watcher.poll() == [] + assert watcher.poll() == [] + + def test_acknowledged_content_is_not_reported_again(self, imported): + watcher, asset, path = imported + _write(path, 30, 2_000_000_000) + watcher.poll() + assert watcher.poll() == [asset] + watcher.acknowledge(asset) + os.utime(path, ns=(4_000_000_000, 4_000_000_000)) + + assert watcher.poll() == [] + assert watcher.poll() == [] + + _write(path, 40, 6_000_000_000) + watcher.poll() + assert watcher.poll() == [asset] + + def test_missing_file_is_not_reported(self, imported): + watcher, _asset, path = imported + path.unlink() + + assert watcher.poll() == [] + assert watcher.poll() == [] + + def test_check_now_reports_without_waiting(self, editor, tmp_path): + path = tmp_path / "part.svg" + _write(path, 20, 1_000_000_000) + asset = _import(editor, path) + _write(path, 30, 2_000_000_000) + + watcher = SourceWatcher(editor) + + assert watcher.check_now() == [asset] + assert watcher.poll() == [] + + def test_unused_source_is_not_watched(self, editor, tmp_path): + path = tmp_path / "part.svg" + _write(path, 20, 1_000_000_000) + asset = _import(editor, path) + for wp in list(editor.doc.all_workpieces): + wp.parent.remove_child(wp) + _write(path, 30, 2_000_000_000) + + assert SourceWatcher(editor).check_now() == [] + assert asset.uid in editor.doc.source_assets + + def test_reloaded_and_undone_content_is_not_reported( + self, editor, imported + ): + watcher, asset, path = imported + _write(path, 30, 2_000_000_000) + watcher.poll() + assert watcher.poll() == [asset] + editor.reload.reload_source(asset) + watcher.acknowledge(asset) + editor.history_manager.undo() + + assert watcher.poll() == [] + assert watcher.poll() == [] + + def test_follows_document_switch(self, editor, imported, tmp_path): + watcher, _asset, _path = imported + editor.set_doc(Doc()) + other = tmp_path / "other.svg" + _write(other, 20, 1_000_000_000) + asset = _import(editor, other) + _write(other, 30, 2_000_000_000) + + assert watcher.check_now() == [asset] + + +class TestAutoReloadFlag: + def test_defaults_to_false(self, editor, tmp_path): + path = tmp_path / "part.svg" + _write(path, 20, 1_000_000_000) + assert _import(editor, path).auto_reload is False + + def test_round_trips_through_dict(self, editor, tmp_path): + path = tmp_path / "part.svg" + _write(path, 20, 1_000_000_000) + asset = _import(editor, path) + asset.auto_reload = True + + data = asset.to_dict() + restored = SourceAsset.from_dict(data) + + assert data["auto_reload"] is True + assert restored.auto_reload is True + assert "auto_reload" not in restored.extra + + def test_old_projects_load_without_flag(self, editor, tmp_path): + path = tmp_path / "part.svg" + _write(path, 20, 1_000_000_000) + data = _import(editor, path).to_dict() + del data["auto_reload"] + + assert SourceAsset.from_dict(data).auto_reload is False From f8dee9efb0c8f2a456bc63e2cb698470774640e2 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Fri, 9 Oct 2026 11:34:52 +0200 Subject: [PATCH 016/104] feat: ask to reload imported files that changed on disk The main window polls imported files every two seconds through a SourceReloadMonitor and, when files changed, shows one dialog listing them. Selected files are reloaded in place; unselected or ignored ones are not offered again until they change again. Files can be marked to reload automatically from then on. Opening a project checks right away. Refs #513 --- .../ui_gtk/doceditor/source_changed_dialog.py | 103 ++++++++++++ rayforge/ui_gtk/doceditor/source_reload.py | 113 +++++++++++++ rayforge/ui_gtk/mainwindow.py | 9 ++ .../doceditor/test_source_changed_dialog.py | 149 ++++++++++++++++++ 4 files changed, 374 insertions(+) create mode 100644 rayforge/ui_gtk/doceditor/source_changed_dialog.py create mode 100644 rayforge/ui_gtk/doceditor/source_reload.py create mode 100644 tests/ui_gtk/doceditor/test_source_changed_dialog.py diff --git a/rayforge/ui_gtk/doceditor/source_changed_dialog.py b/rayforge/ui_gtk/doceditor/source_changed_dialog.py new file mode 100644 index 000000000..cc6ab8b2e --- /dev/null +++ b/rayforge/ui_gtk/doceditor/source_changed_dialog.py @@ -0,0 +1,103 @@ +"""Dialog asking whether imported files that changed should be reloaded.""" + +from collections.abc import Callable +from gettext import gettext as _ +from gettext import ngettext + +from gi.repository import Adw, Gtk + +from ...core.source_asset import SourceAsset + +ReloadCallback = Callable[[list[SourceAsset], bool, list[SourceAsset]], None] +IgnoreCallback = Callable[[list[SourceAsset]], None] + + +class SourceChangedDialog(Adw.AlertDialog): + """ + Lists imported files that changed on disk and lets the user choose + which of them to reload, and whether to reload them automatically + from now on. + """ + + def __init__( + self, + assets: list[SourceAsset], + on_reload: ReloadCallback | None = None, + on_ignore: IgnoreCallback | None = None, + ): + super().__init__() + self._assets = list(assets) + self._on_reload = on_reload + self._on_ignore = on_ignore + self._checks: dict[str, Gtk.CheckButton] = {} + + count = len(self._assets) + self.set_heading( + ngettext("Imported File Changed", "Imported Files Changed", count) + ) + if count == 1: + body = _( + '"{name}" was changed on disk. Reload it? Position, size ' + "and settings are kept." + ).format(name=self._assets[0].name) + else: + body = _( + "These imported files were changed on disk. Reload them? " + "Position, size and settings are kept." + ) + self.set_body(body) + self.set_extra_child(self._build_options(count)) + + self.add_response("ignore", _("_Ignore")) + self.add_response("reload", _("_Reload")) + self.set_response_appearance( + "reload", Adw.ResponseAppearance.SUGGESTED + ) + self.set_default_response("reload") + self.set_close_response("ignore") + self.connect("response", self._on_response) + + def _build_options(self, count: int) -> Gtk.Widget: + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) + if count > 1: + for asset in self._assets: + check = Gtk.CheckButton(label=asset.name, active=True) + check.set_tooltip_text(str(asset.source_file)) + self._checks[asset.uid] = check + box.append(check) + self._always = Gtk.CheckButton( + label=ngettext( + "Always reload this file without asking", + "Always reload the selected files without asking", + count, + ) + ) + box.append(self._always) + return box + + def set_selected(self, asset: SourceAsset, selected: bool) -> None: + check = self._checks.get(asset.uid) + if check is not None: + check.set_active(selected) + + def set_always(self, always: bool) -> None: + self._always.set_active(always) + + def selected_assets(self) -> list[SourceAsset]: + return [ + asset + for asset in self._assets + if asset.uid not in self._checks + or self._checks[asset.uid].get_active() + ] + + def _on_response(self, dialog, response_id: str): + if response_id == "reload": + selected = self.selected_assets() + skipped = [a for a in self._assets if a not in selected] + if self._on_reload: + self._on_reload(selected, self._always.get_active(), skipped) + elif self._on_ignore: + self._on_ignore(list(self._assets)) + if self.get_root() is not None: + self.close() diff --git a/rayforge/ui_gtk/doceditor/source_reload.py b/rayforge/ui_gtk/doceditor/source_reload.py new file mode 100644 index 000000000..647e74df3 --- /dev/null +++ b/rayforge/ui_gtk/doceditor/source_reload.py @@ -0,0 +1,113 @@ +"""Watches imported files and offers to reload them when they change.""" + +from __future__ import annotations + +import logging +from collections.abc import Callable +from typing import TYPE_CHECKING, Any + +from gi.repository import GLib + +from ...core.source_asset import SourceAsset +from ...doceditor.source_watcher import SourceWatcher +from .source_changed_dialog import SourceChangedDialog + +if TYPE_CHECKING: + from gi.repository import Gtk + + from ...doceditor.editor import DocEditor + +logger = logging.getLogger(__name__) + +POLL_INTERVAL_SECONDS = 2 + + +class SourceReloadMonitor: + """ + Polls the files behind imported sources and asks the user whether to + reload the ones that changed. Sources marked for automatic reload are + reloaded without asking. + """ + + def __init__( + self, + parent: Gtk.Widget, + editor: DocEditor, + watcher: SourceWatcher | None = None, + dialog_factory: Callable[..., Any] = SourceChangedDialog, + ): + self._parent = parent + self._editor = editor + self._watcher = watcher or SourceWatcher(editor) + self._dialog_factory = dialog_factory + self._dialog: Any | None = None + self._queue: list[SourceAsset] = [] + self._timeout_id: int | None = None + + def start(self) -> None: + if self._timeout_id is None: + self._timeout_id = GLib.timeout_add_seconds( + POLL_INTERVAL_SECONDS, self.tick + ) + + def stop(self) -> None: + if self._timeout_id is not None: + GLib.source_remove(self._timeout_id) + self._timeout_id = None + + def tick(self) -> bool: + try: + self._handle(self._watcher.poll()) + except Exception: + logger.exception("Checking imported files for changes failed") + return True + + def check_now(self) -> None: + """Checks all imported files right away, e.g. after opening.""" + self._handle(self._watcher.check_now()) + + def _handle(self, changed: list[SourceAsset]) -> None: + for asset in changed: + if asset.auto_reload: + self._reload(asset) + elif all(asset.uid != queued.uid for queued in self._queue): + self._queue.append(asset) + self._show_next() + + def _show_next(self) -> None: + if self._dialog is not None or not self._queue: + return + assets, self._queue = self._queue, [] + dialog = self._dialog_factory( + assets, + on_reload=self._on_reload, + on_ignore=self._on_ignore, + ) + self._dialog = dialog + dialog.present(self._parent) + + def _reload(self, asset: SourceAsset) -> None: + self._editor.reload.reload_source(asset) + self._watcher.acknowledge(asset) + + def _on_reload( + self, + selected: list[SourceAsset], + always: bool, + skipped: list[SourceAsset], + ) -> None: + self._dialog = None + for asset in selected: + if always and not asset.auto_reload: + asset.auto_reload = True + self._editor.mark_as_unsaved() + self._reload(asset) + for asset in skipped: + self._watcher.acknowledge(asset) + self._show_next() + + def _on_ignore(self, assets: list[SourceAsset]) -> None: + self._dialog = None + for asset in assets: + self._watcher.acknowledge(asset) + self._show_next() diff --git a/rayforge/ui_gtk/mainwindow.py b/rayforge/ui_gtk/mainwindow.py index f0928cd82..44c5aed66 100644 --- a/rayforge/ui_gtk/mainwindow.py +++ b/rayforge/ui_gtk/mainwindow.py @@ -49,6 +49,7 @@ from .doceditor.item_properties import DocItemPropertiesWidget from .doceditor.missing_features_dialog import MissingFeaturesDialog from .doceditor.property_providers import register_builtin_providers +from .doceditor.source_reload import SourceReloadMonitor from .doceditor.workflow_view import WorkflowView from .machine.machine_dropdown import MachineDropdown from .machine.profile_review_dialog import ( @@ -195,6 +196,8 @@ def __init__(self, **kwargs): # Instantiate UI-specific command handlers self.view_cmd = ViewModeCmd(self.doc_editor, self) self.project_cmd = ProjectCmd(self, self.doc_editor) + self.source_reload = SourceReloadMonitor(self, self.doc_editor) + self.source_reload.start() geometry = get_monitor_geometry() if geometry: @@ -1620,6 +1623,8 @@ def _on_document_changed(self, sender): # Initialize new document self._initialize_document() + GLib.idle_add(self._check_changed_sources) + # Check for missing step types and show dialog if needed missing_types = new_doc.missing_step_types if missing_types: @@ -1632,6 +1637,10 @@ def _on_document_changed(self, sender): # Update the UI with the new document's content self.on_doc_changed(new_doc) + def _check_changed_sources(self) -> bool: + self.source_reload.check_now() + return GLib.SOURCE_REMOVE + def _on_editor_notification( self, sender, diff --git a/tests/ui_gtk/doceditor/test_source_changed_dialog.py b/tests/ui_gtk/doceditor/test_source_changed_dialog.py new file mode 100644 index 000000000..840b7e22e --- /dev/null +++ b/tests/ui_gtk/doceditor/test_source_changed_dialog.py @@ -0,0 +1,149 @@ +"""Tests for the dialog and monitor that reload changed source files.""" + +from pathlib import Path +from typing import Any, ClassVar +from unittest.mock import MagicMock + +import pytest + +from rayforge.core.source_asset import SourceAsset +from rayforge.image.svg.renderer import SVG_RENDERER +from rayforge.ui_gtk.doceditor.source_changed_dialog import ( + SourceChangedDialog, +) +from rayforge.ui_gtk.doceditor.source_reload import SourceReloadMonitor + +pytestmark = pytest.mark.ui + + +def _asset(name: str, auto_reload: bool = False) -> SourceAsset: + return SourceAsset( + source_file=Path("/tmp") / name, + original_data=b"", + renderer=SVG_RENDERER, + auto_reload=auto_reload, + ) + + +class TestSourceChangedDialog: + def test_single_file_names_it(self): + dialog = SourceChangedDialog([_asset("part.svg")]) + assert dialog.get_heading() == "Imported File Changed" + assert "part.svg" in dialog.get_body() + + def test_several_files_are_listed(self): + assets = [_asset("a.svg"), _asset("b.svg")] + dialog = SourceChangedDialog(assets) + assert dialog.get_heading() == "Imported Files Changed" + assert dialog.selected_assets() == assets + + def test_defaults(self): + dialog = SourceChangedDialog([_asset("part.svg")]) + assert dialog.get_default_response() == "reload" + assert dialog.get_close_response() == "ignore" + + def test_reload_passes_selection_and_choice(self): + assets = [_asset("a.svg"), _asset("b.svg")] + on_reload = MagicMock() + dialog = SourceChangedDialog(assets, on_reload=on_reload) + dialog.set_selected(assets[0], False) + dialog.set_always(True) + + dialog._on_response(dialog, "reload") + + on_reload.assert_called_once_with([assets[1]], True, [assets[0]]) + + def test_ignore_passes_all_assets(self): + assets = [_asset("a.svg"), _asset("b.svg")] + on_ignore = MagicMock() + dialog = SourceChangedDialog(assets, on_ignore=on_ignore) + + dialog._on_response(dialog, "ignore") + + on_ignore.assert_called_once_with(assets) + + +class _FakeDialog: + instances: ClassVar[list["_FakeDialog"]] = [] + + def __init__(self, assets, on_reload, on_ignore): + self.assets = assets + self.on_reload: Any = on_reload + self.on_ignore: Any = on_ignore + self.presented = False + _FakeDialog.instances.append(self) + + def present(self, parent): + self.presented = True + + +@pytest.fixture +def monitor(): + _FakeDialog.instances = [] + editor = MagicMock() + watcher = MagicMock() + monitor = SourceReloadMonitor( + MagicMock(), editor, watcher=watcher, dialog_factory=_FakeDialog + ) + return monitor, editor, watcher + + +class TestSourceReloadMonitor: + def test_changed_files_open_one_dialog(self, monitor): + mon, _editor, watcher = monitor + assets = [_asset("a.svg"), _asset("b.svg")] + watcher.poll.return_value = assets + + assert mon.tick() is True + + (dialog,) = _FakeDialog.instances + assert dialog.presented + assert dialog.assets == assets + + def test_auto_reload_files_reload_without_asking(self, monitor): + mon, editor, watcher = monitor + asset = _asset("a.svg", auto_reload=True) + watcher.poll.return_value = [asset] + + mon.tick() + + editor.reload.reload_source.assert_called_once_with(asset) + watcher.acknowledge.assert_called_once_with(asset) + assert _FakeDialog.instances == [] + + def test_further_changes_wait_for_the_open_dialog(self, monitor): + mon, _editor, watcher = monitor + first, second = _asset("a.svg"), _asset("b.svg") + watcher.poll.return_value = [first] + mon.tick() + watcher.poll.return_value = [second] + mon.tick() + assert len(_FakeDialog.instances) == 1 + + _FakeDialog.instances[0].on_ignore([first]) + + assert len(_FakeDialog.instances) == 2 + assert _FakeDialog.instances[1].assets == [second] + + def test_reload_choice_is_applied(self, monitor): + mon, editor, watcher = monitor + keep, skip = _asset("a.svg"), _asset("b.svg") + watcher.poll.return_value = [keep, skip] + mon.tick() + + _FakeDialog.instances[0].on_reload([keep], True, [skip]) + + editor.reload.reload_source.assert_called_once_with(keep) + assert keep.auto_reload is True + assert skip.auto_reload is False + editor.mark_as_unsaved.assert_called_once() + watcher.acknowledge.assert_any_call(keep) + + def test_check_now_uses_immediate_check(self, monitor): + mon, _editor, watcher = monitor + asset = _asset("a.svg") + watcher.check_now.return_value = [asset] + + mon.check_now() + + assert _FakeDialog.instances[0].assets == [asset] From 6dc0e415791f8d431519f5764b8a550efde650d4 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Fri, 9 Oct 2026 12:08:49 +0200 Subject: [PATCH 017/104] feat: relink imported sources and add a watch preference ReloadCmd can now point a source at another file and reload it from there in one undoable step, and lists the sources behind a selection. Config gains watch_source_files (on by default). Refs #513 --- rayforge/core/config.py | 12 ++++ rayforge/doceditor/reload_cmd.py | 65 +++++++++++++++++---- tests/core/test_config.py | 33 +++++++++++ tests/doceditor/test_reload_cmd.py | 94 ++++++++++++++++++++++++++++++ 4 files changed, 192 insertions(+), 12 deletions(-) diff --git a/rayforge/core/config.py b/rayforge/core/config.py index fcc8939c4..1da9174ba 100644 --- a/rayforge/core/config.py +++ b/rayforge/core/config.py @@ -101,6 +101,9 @@ def __init__(self): self.auto_pipeline: bool = True self.ops_color_mode: OpsColorMode = OpsColorMode.LASER self.check_for_app_updates: bool = True + # Whether to watch imported files and offer to reload them when + # they change on disk. + self.watch_source_files: bool = True # Usage tracking consent date: None = not asked, "" = declined, # ISO date string = consent given on that date self.usage_consent_date: str | None = None @@ -221,6 +224,13 @@ def set_check_for_app_updates(self, enabled: bool): self.check_for_app_updates = enabled self.changed.send(self) + def set_watch_source_files(self, enabled: bool): + """Sets whether imported files are watched for changes.""" + if self.watch_source_files == enabled: + return + self.watch_source_files = enabled + self.changed.send(self) + def set_ops_color_mode(self, mode: OpsColorMode): """Sets the ops color mode.""" if self.ops_color_mode == mode: @@ -342,6 +352,7 @@ def to_dict(self) -> dict[str, Any]: "canvas_view": self.canvas_view.to_dict(), "auto_pipeline": self.auto_pipeline, "check_for_app_updates": self.check_for_app_updates, + "watch_source_files": self.watch_source_files, "ops_color_mode": self.ops_color_mode.value, "usage_consent_date": self.usage_consent_date, "import_dpi": self.import_dpi, @@ -406,6 +417,7 @@ def from_dict(cls, data: dict[str, Any], get_machine_by_id) -> "Config": ) config.auto_pipeline = data.get("auto_pipeline", True) config.check_for_app_updates = data.get("check_for_app_updates", True) + config.watch_source_files = data.get("watch_source_files", True) ops_color_mode_str = data.get( "ops_color_mode", OpsColorMode.LASER.value diff --git a/rayforge/doceditor/reload_cmd.py b/rayforge/doceditor/reload_cmd.py index 125641a07..1f8788cbc 100644 --- a/rayforge/doceditor/reload_cmd.py +++ b/rayforge/doceditor/reload_cmd.py @@ -116,9 +116,7 @@ def __init__( super().__init__(name) self.asset = asset self.new_values = new_values - self.old_values = { - key: getattr(asset, key) for key in _SOURCE_DATA_FIELDS - } + self.old_values = {key: getattr(asset, key) for key in new_values} def _apply(self, values: dict[str, Any]) -> None: for key, value in values.items(): @@ -261,6 +259,27 @@ def workpieces_for(self, asset: SourceAsset) -> list[WorkPiece]: and wp.source_segment.source_asset_uid == asset.uid ] + def sources_of(self, items: list[DocItem]) -> list[SourceAsset]: + """Returns the distinct sources the given workpieces came from.""" + sources: list[SourceAsset] = [] + for item in items: + if not isinstance(item, WorkPiece) or not item.source_segment: + continue + asset = self._editor.doc.get_source_asset_by_uid( + item.source_segment.source_asset_uid + ) + if asset is not None and all( + asset.uid != known.uid for known in sources + ): + sources.append(asset) + return sources + + def can_relink(self, asset: SourceAsset) -> bool: + return bool(asset.metadata.get("_importer_class")) + + def can_reload(self, asset: SourceAsset) -> bool: + return self.can_relink(asset) and asset.source_file.is_file() + def reload_source( self, asset: SourceAsset, data: bytes | None = None ) -> ReloadReport: @@ -269,13 +288,25 @@ def reload_source( file on disk) and updates every workpiece made from it in place, as a single undoable step. """ + return self._reload(asset, asset.source_file, data) + + def relink_source(self, asset: SourceAsset, path: Path) -> ReloadReport: + """ + Points a source at another file and reloads it from there, as a + single undoable step. + """ + return self._reload(asset, path, None) + + def _reload( + self, asset: SourceAsset, path: Path, data: bytes | None + ) -> ReloadReport: report = ReloadReport(source_name=asset.name) try: if data is None: - data = asset.source_file.read_bytes() - plan = self._plan(asset, data, report) + data = path.read_bytes() + plan = self._plan(asset, path, data, report) except Exception as e: - logger.exception(f"Reload of {asset.source_file} failed") + logger.exception(f"Reload of {path} failed") report.error = str(e) self._editor.notification_requested.send( self, message=report.describe() @@ -328,6 +359,7 @@ def _spec_for_new_data( self, importer_cls: type[Importer], asset: SourceAsset, + path: Path, data: bytes, spec: VectorizationSpec, ) -> VectorizationSpec: @@ -337,7 +369,6 @@ def _spec_for_new_data( """ if not isinstance(spec, PassthroughSpec) or not spec.active_layer_ids: return spec - path = asset.source_file old_ids = self._layer_ids( importer_cls, asset.original_data, path, spec ) @@ -347,7 +378,11 @@ def _spec_for_new_data( return replace(spec, active_layer_ids=new_ids) def _plan( - self, asset: SourceAsset, data: bytes, report: ReloadReport + self, + asset: SourceAsset, + path: Path, + data: bytes, + report: ReloadReport, ) -> _Plan: importer_cls = self._importer_class(asset) plan = _Plan() @@ -362,12 +397,12 @@ def _plan( new_source = None for workpieces in groups.values(): result = self._plan_group( - importer_cls, asset, data, workpieces, plan, report + importer_cls, asset, path, data, workpieces, plan, report ) new_source = new_source or _source_of(result) if new_source is None: - result = self._run(importer_cls, data, asset.source_file, None) + result = self._run(importer_cls, data, path, None) new_source = _source_of(result) metadata = dict(new_source.metadata) @@ -378,12 +413,17 @@ def _plan( key: getattr(new_source, key) for key in _SOURCE_DATA_FIELDS } plan.source_values["metadata"] = metadata + if path != asset.source_file: + plan.source_values["source_file"] = path + if asset.name == asset.source_file.name: + plan.source_values["name"] = path.name return plan def _plan_group( self, importer_cls: type[Importer], asset: SourceAsset, + path: Path, data: bytes, workpieces: list[WorkPiece], plan: _Plan, @@ -392,8 +432,9 @@ def _plan_group( segment = workpieces[0].source_segment assert segment is not None spec = segment.vectorization_spec - path = asset.source_file - new_spec = self._spec_for_new_data(importer_cls, asset, data, spec) + new_spec = self._spec_for_new_data( + importer_cls, asset, path, data, spec + ) old_result = self._run(importer_cls, asset.original_data, path, spec) new_result = self._run(importer_cls, data, path, new_spec) if not _fresh_items(new_result): diff --git a/tests/core/test_config.py b/tests/core/test_config.py index 1f53c15d9..bc84b5a5e 100644 --- a/tests/core/test_config.py +++ b/tests/core/test_config.py @@ -148,3 +148,36 @@ def test_malformed_entries_are_dropped(self): assert restored.gesture_bindings == { "canvas3d": {"pan": "drag+middle"} } + + +class TestWatchSourceFiles: + def test_defaults_to_true(self): + assert Config().watch_source_files is True + + def test_setter_emits_changed_once(self): + config = Config() + calls = [] + + def on_changed(sender, **kwargs): + calls.append(sender) + + config.changed.connect(on_changed, weak=False) + + config.set_watch_source_files(False) + config.set_watch_source_files(False) + + assert len(calls) == 1 + + def test_round_trip(self): + config = Config() + config.set_watch_source_files(False) + data = config.to_dict() + + restored = Config.from_dict(data, get_machine_by_id=lambda mid: None) + + assert data["watch_source_files"] is False + assert restored.watch_source_files is False + + def test_absent_key_falls_back_to_true(self): + restored = Config.from_dict({}, get_machine_by_id=lambda mid: None) + assert restored.watch_source_files is True diff --git a/tests/doceditor/test_reload_cmd.py b/tests/doceditor/test_reload_cmd.py index 8a7f8c56d..f72df997a 100644 --- a/tests/doceditor/test_reload_cmd.py +++ b/tests/doceditor/test_reload_cmd.py @@ -497,3 +497,97 @@ def test_unchanged_content_stays_put(self, editor, tmp_path): new_world = wp.get_world_geometry() assert new_world is not None _assert_contained(old_world, new_world) + + +class TestRelink: + def test_relink_reads_new_path_and_keeps_placement(self, editor, tmp_path): + old_path = _write(tmp_path / "a.svg", PAGE_80, RED_RECT, BLUE_CIRCLE) + asset = _import(editor, old_path, _flatten_spec()) + (wp,) = _workpieces_of(editor.doc, asset) + _transform(wp, Matrix.translation(9, 4) @ Matrix.rotation(10)) + old_world = wp.get_world_geometry() + assert old_world is not None + (tmp_path / "moved").mkdir() + new_path = _write( + tmp_path / "moved" / "b.svg", + PAGE_80, + RED_RECT, + BLUE_CIRCLE, + GREEN_SQUARE, + ) + old_path.unlink() + + report = editor.reload.relink_source(asset, new_path) + + assert report.error is None + assert report.updated == 1 + assert asset.source_file == new_path + assert asset.name == "b.svg" + assert asset.original_data == new_path.read_bytes() + new_world = wp.get_world_geometry() + assert new_world is not None + _assert_contained(old_world, new_world) + + def test_relink_is_undoable(self, editor, tmp_path): + old_path = _write(tmp_path / "a.svg", PAGE_80, RED_RECT) + asset = _import(editor, old_path, _flatten_spec()) + old_data = asset.original_data + new_path = _write(tmp_path / "b.svg", PAGE_80, WIDE_RED_RECT) + + editor.reload.relink_source(asset, new_path) + editor.history_manager.undo() + + assert asset.source_file == old_path + assert asset.name == "a.svg" + assert asset.original_data == old_data + + def test_relink_keeps_a_custom_name(self, editor, tmp_path): + old_path = _write(tmp_path / "a.svg", PAGE_80, RED_RECT) + asset = _import(editor, old_path, _flatten_spec()) + asset.name = "Logo" + new_path = _write(tmp_path / "b.svg", PAGE_80, RED_RECT) + + editor.reload.relink_source(asset, new_path) + + assert asset.name == "Logo" + + def test_relink_to_unreadable_path_changes_nothing(self, editor, tmp_path): + old_path = _write(tmp_path / "a.svg", PAGE_80, RED_RECT) + asset = _import(editor, old_path, _flatten_spec()) + undo_depth = len(editor.history_manager.undo_stack) + + report = editor.reload.relink_source(asset, tmp_path / "nope.svg") + + assert report.error + assert asset.source_file == old_path + assert len(editor.history_manager.undo_stack) == undo_depth + + +class TestSourceLookup: + def test_sources_of_selection_are_distinct(self, editor, tmp_path): + a = _import( + editor, + _write(tmp_path / "a.svg", PAGE_80, RED_RECT, BLUE_CIRCLE), + _color_spec(), + ) + b = _import( + editor, + _write(tmp_path / "b.svg", PAGE_80, RED_RECT), + _flatten_spec(), + ) + items = editor.doc.all_workpieces + [WorkPiece(name="plain")] + + sources = editor.reload.sources_of(items) + + assert sorted(s.uid for s in sources) == sorted([a.uid, b.uid]) + + def test_reloadable_needs_existing_file(self, editor, tmp_path): + path = _write(tmp_path / "a.svg", PAGE_80, RED_RECT) + asset = _import(editor, path, _flatten_spec()) + assert editor.reload.can_reload(asset) + assert editor.reload.can_relink(asset) + + path.unlink() + + assert not editor.reload.can_reload(asset) + assert editor.reload.can_relink(asset) From 0fa62b25d63c8870a5de72f665544279ef5160f0 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Fri, 9 Oct 2026 12:08:49 +0200 Subject: [PATCH 018/104] feat: add reload and relink actions and a watch preference Reload from Disk and Relink Source File are offered in the File menu, the canvas and layer list context menus and the asset browser. Relink opens a file chooser and reloads from the chosen file. The General preferences get a switch to turn watching imported files off. Refs #513 --- rayforge/ui_gtk/actions.py | 53 ++++++++++ rayforge/ui_gtk/canvas2d/context_menu.py | 10 ++ rayforge/ui_gtk/doceditor/asset_browser.py | 14 +++ rayforge/ui_gtk/doceditor/file_dialogs.py | 27 +++++- rayforge/ui_gtk/doceditor/layer_column.py | 9 ++ rayforge/ui_gtk/doceditor/source_reload.py | 36 +++++++ rayforge/ui_gtk/main_menu.py | 4 + .../settings/general_preferences_page.py | 17 ++++ .../doceditor/test_source_changed_dialog.py | 97 ++++++++++++++++++- 9 files changed, 264 insertions(+), 3 deletions(-) diff --git a/rayforge/ui_gtk/actions.py b/rayforge/ui_gtk/actions.py index 4327dad2d..45e60507b 100644 --- a/rayforge/ui_gtk/actions.py +++ b/rayforge/ui_gtk/actions.py @@ -9,6 +9,7 @@ from ..core.group import Group from ..core.item import DocItem from ..core.layer import Layer +from ..core.source_asset import SourceAsset from ..core.stock import StockItem from ..core.workpiece import WorkPiece from ..doceditor.array import ArrayMode @@ -411,6 +412,12 @@ def register_actions(self): "asset-create-workpiece", self.on_asset_create_workpiece, ) + self._add_action("asset-reload-source", self.on_asset_reload_source) + self._add_action("asset-relink-source", self.on_asset_relink_source) + + # Imported Source Actions + self._add_action("reload-source", self.on_reload_source) + self._add_action("relink-source", self.on_relink_source) # Layer Management Actions self._add_action("layer-move-up", self.on_layer_move_up) @@ -552,6 +559,14 @@ def update_action_states(self, *args, **kwargs): self.actions["split"].set_enabled(bool(selected_wps)) self.actions["export-object"].set_enabled(len(selected_wps) == 1) + sources = self.editor.reload.sources_of(list(selected_wps)) + self.actions["reload-source"].set_enabled( + any(self.editor.reload.can_reload(s) for s in sources) + ) + self.actions["relink-source"].set_enabled( + len(sources) == 1 and self.editor.reload.can_relink(sources[0]) + ) + action_extension_registry.invoke_state_update_handlers(self) def on_add_stock(self, action, param): @@ -602,6 +617,44 @@ def on_asset_create_workpiece(self, action, param): browser = self.win.bottom_panel.asset_browser browser.create_workpiece_from_selected() + def _selected_sources(self) -> list[SourceAsset]: + selected = self.win.surface.get_selected_workpieces() + return self.editor.reload.sources_of(list(selected)) + + def _browser_sources(self) -> list[SourceAsset]: + browser = self.win.bottom_panel.asset_browser + return [ + asset + for asset in browser.get_selected_assets() + if isinstance(asset, SourceAsset) + ] + + def _reload_sources(self, sources: list[SourceAsset]): + reloadable = [s for s in sources if self.editor.reload.can_reload(s)] + if reloadable: + self.win.source_reload.reload_assets(reloadable) + elif sources: + message = _( + "The file of {name} cannot be found. Use Relink to pick it." + ).format(name=sources[0].name) + self.editor.notification_requested.send(self, message=message) + + def _relink_sources(self, sources: list[SourceAsset]): + if len(sources) == 1 and self.editor.reload.can_relink(sources[0]): + self.win.source_reload.choose_relink(sources[0]) + + def on_reload_source(self, action, param): + self._reload_sources(self._selected_sources()) + + def on_relink_source(self, action, param): + self._relink_sources(self._selected_sources()) + + def on_asset_reload_source(self, action, param): + self._reload_sources(self._browser_sources()) + + def on_asset_relink_source(self, action, param): + self._relink_sources(self._browser_sources()) + def _update_asset_action_states(self): browser = self.win.bottom_panel.asset_browser self.actions["asset-paste"].set_enabled(browser.can_paste_assets()) diff --git a/rayforge/ui_gtk/canvas2d/context_menu.py b/rayforge/ui_gtk/canvas2d/context_menu.py index dae5fcb0e..427bd96c6 100644 --- a/rayforge/ui_gtk/canvas2d/context_menu.py +++ b/rayforge/ui_gtk/canvas2d/context_menu.py @@ -121,6 +121,16 @@ def _populate_standard_items(menu: Gio.Menu): # Separator menu.append_section(None, Gio.Menu.new()) + menu.append_item( + Gio.MenuItem.new(_("Reload from Disk"), "win.reload-source") + ) + menu.append_item( + Gio.MenuItem.new(_("Relink Source File\u2026"), "win.relink-source") + ) + + # Separator + menu.append_section(None, Gio.Menu.new()) + menu.append_item(Gio.MenuItem.new(_("Remove"), "win.remove")) diff --git a/rayforge/ui_gtk/doceditor/asset_browser.py b/rayforge/ui_gtk/doceditor/asset_browser.py index 462d4a9b0..4d632f56a 100644 --- a/rayforge/ui_gtk/doceditor/asset_browser.py +++ b/rayforge/ui_gtk/doceditor/asset_browser.py @@ -12,6 +12,7 @@ from ...core.asset_registry import asset_type_registry from ...core.doc import Doc from ...core.geometry_provider import IGeometryProvider +from ...core.source_asset import SourceAsset from ...core.stock import StockItem from ...core.undo import ListItemCommand from ..icons import get_icon @@ -547,6 +548,19 @@ def _show_asset_context_menu(self, gesture, asset: IAsset): ) menu.append_section(None, Gio.Menu.new()) + if isinstance(asset, SourceAsset): + menu.append_item( + Gio.MenuItem.new( + _("Reload from Disk"), "win.asset-reload-source" + ) + ) + menu.append_item( + Gio.MenuItem.new( + _("Relink Source File\u2026"), "win.asset-relink-source" + ) + ) + menu.append_section(None, Gio.Menu.new()) + menu.append_item( Gio.MenuItem.new(_("Duplicate"), "win.asset-duplicate") ) diff --git a/rayforge/ui_gtk/doceditor/file_dialogs.py b/rayforge/ui_gtk/doceditor/file_dialogs.py index 2e2cdcb52..a68acb8cc 100644 --- a/rayforge/ui_gtk/doceditor/file_dialogs.py +++ b/rayforge/ui_gtk/doceditor/file_dialogs.py @@ -10,6 +10,7 @@ from ..shared.gtk import file_filter_to_gtk if TYPE_CHECKING: + from ...core.source_asset import SourceAsset from ...core.workpiece import WorkPiece from ...doceditor.editor import DocEditor from ..mainwindow import MainWindow @@ -35,7 +36,31 @@ def show_import_dialog( """ dialog = Gtk.FileDialog.new() dialog.set_title(_("Open File")) + _set_import_filters(dialog) + dialog.open(win, None, callback, user_data) + +def show_relink_dialog( + win: Gtk.Window, + asset: "SourceAsset", + callback: Callable, + user_data: Any = None, +): + """ + Shows the file chooser dialog for pointing an imported source at + another file. + """ + dialog = Gtk.FileDialog.new() + dialog.set_title(_("Relink \u201c{name}\u201d").format(name=asset.name)) + _set_import_filters(dialog) + folder = asset.source_file.parent + if folder.is_dir(): + dialog.set_initial_folder(Gio.File.new_for_path(str(folder))) + dialog.open(win, None, callback, user_data) + + +def _set_import_filters(dialog: Gtk.FileDialog): + """Offers every file type that an importer can read.""" filter_list = Gio.ListStore.new(Gtk.FileFilter) all_supported = Gtk.FileFilter() all_supported.set_name(_("All supported")) @@ -61,8 +86,6 @@ def show_import_dialog( dialog.set_filters(filter_list) dialog.set_default_filter(all_supported) - dialog.open(win, None, callback, user_data) - def show_export_gcode_dialog( win: "MainWindow", diff --git a/rayforge/ui_gtk/doceditor/layer_column.py b/rayforge/ui_gtk/doceditor/layer_column.py index 1c5e8eac1..296d0a197 100644 --- a/rayforge/ui_gtk/doceditor/layer_column.py +++ b/rayforge/ui_gtk/doceditor/layer_column.py @@ -622,6 +622,15 @@ def _show_item_context_menu(self, gesture, item): menu.append_section(None, Gio.Menu.new()) menu.append_item(Gio.MenuItem.new(_("Duplicate"), "win.duplicate")) menu.append_section(None, Gio.Menu.new()) + menu.append_item( + Gio.MenuItem.new(_("Reload from Disk"), "win.reload-source") + ) + menu.append_item( + Gio.MenuItem.new( + _("Relink Source File\u2026"), "win.relink-source" + ) + ) + menu.append_section(None, Gio.Menu.new()) menu.append_item(Gio.MenuItem.new(_("Copy"), "win.copy")) menu.append_item(Gio.MenuItem.new(_("Cut"), "win.cut")) menu.append_section(None, Gio.Menu.new()) diff --git a/rayforge/ui_gtk/doceditor/source_reload.py b/rayforge/ui_gtk/doceditor/source_reload.py index 647e74df3..2ab7fd2de 100644 --- a/rayforge/ui_gtk/doceditor/source_reload.py +++ b/rayforge/ui_gtk/doceditor/source_reload.py @@ -4,12 +4,15 @@ import logging from collections.abc import Callable +from pathlib import Path from typing import TYPE_CHECKING, Any from gi.repository import GLib +from ...context import get_context from ...core.source_asset import SourceAsset from ...doceditor.source_watcher import SourceWatcher +from . import file_dialogs from .source_changed_dialog import SourceChangedDialog if TYPE_CHECKING: @@ -35,6 +38,7 @@ def __init__( editor: DocEditor, watcher: SourceWatcher | None = None, dialog_factory: Callable[..., Any] = SourceChangedDialog, + enabled: Callable[[], bool] | None = None, ): self._parent = parent self._editor = editor @@ -43,6 +47,7 @@ def __init__( self._dialog: Any | None = None self._queue: list[SourceAsset] = [] self._timeout_id: int | None = None + self._enabled = enabled or _watching_enabled def start(self) -> None: if self._timeout_id is None: @@ -56,6 +61,8 @@ def stop(self) -> None: self._timeout_id = None def tick(self) -> bool: + if not self._enabled(): + return True try: self._handle(self._watcher.poll()) except Exception: @@ -64,8 +71,33 @@ def tick(self) -> bool: def check_now(self) -> None: """Checks all imported files right away, e.g. after opening.""" + if not self._enabled(): + return self._handle(self._watcher.check_now()) + def reload_assets(self, assets: list[SourceAsset]) -> None: + """Reloads the given sources from disk on request.""" + for asset in assets: + self._reload(asset) + + def choose_relink(self, asset: SourceAsset) -> None: + """Lets the user pick another file for a source.""" + file_dialogs.show_relink_dialog( + self._parent, asset, self._on_relink_response, asset + ) + + def relink_to(self, asset: SourceAsset, path: Path) -> None: + self._editor.reload.relink_source(asset, path) + self._watcher.acknowledge(asset) + + def _on_relink_response(self, dialog, result, asset: SourceAsset): + try: + file = dialog.open_finish(result) + except GLib.Error: + return + if file is not None and file.get_path(): + self.relink_to(asset, Path(file.get_path())) + def _handle(self, changed: list[SourceAsset]) -> None: for asset in changed: if asset.auto_reload: @@ -111,3 +143,7 @@ def _on_ignore(self, assets: list[SourceAsset]) -> None: for asset in assets: self._watcher.acknowledge(asset) self._show_next() + + +def _watching_enabled() -> bool: + return get_context().config.watch_source_files diff --git a/rayforge/ui_gtk/main_menu.py b/rayforge/ui_gtk/main_menu.py index 2c1f4cfe2..3abc15f51 100644 --- a/rayforge/ui_gtk/main_menu.py +++ b/rayforge/ui_gtk/main_menu.py @@ -37,6 +37,10 @@ def __init__(self): import_export_group = Gio.Menu() import_export_group.append(_("Import..."), "win.import") + import_export_group.append(_("Reload from Disk"), "win.reload-source") + import_export_group.append( + _("Relink Source File..."), "win.relink-source" + ) import_export_group.append(_("Export G-code..."), "win.export") import_export_group.append( _("Export Document..."), "win.export_document" diff --git a/rayforge/ui_gtk/settings/general_preferences_page.py b/rayforge/ui_gtk/settings/general_preferences_page.py index b54fdf904..abedbef66 100644 --- a/rayforge/ui_gtk/settings/general_preferences_page.py +++ b/rayforge/ui_gtk/settings/general_preferences_page.py @@ -317,6 +317,18 @@ def __init__(self, **kwargs): ) startup_group.add(self.check_updates_row) + self.watch_sources_row = Adw.SwitchRow( + title=_("Watch imported files"), + subtitle=_( + "Offer to reload imported files when they change on disk" + ), + ) + self.watch_sources_row.set_active(config.watch_source_files) + self.watch_sources_row.connect( + "notify::active", self.on_watch_sources_changed + ) + startup_group.add(self.watch_sources_row) + # Startup behavior selector self.startup_behavior_row = Adw.ComboRow( title=_("Startup behavior"), @@ -566,3 +578,8 @@ def on_check_updates_changed(self, switch_row, _): """Called when the user toggles the update check setting.""" enabled = switch_row.get_active() get_context().config.set_check_for_app_updates(enabled) + + def on_watch_sources_changed(self, switch_row, _): + """Called when the user toggles watching imported files.""" + enabled = switch_row.get_active() + get_context().config.set_watch_source_files(enabled) diff --git a/tests/ui_gtk/doceditor/test_source_changed_dialog.py b/tests/ui_gtk/doceditor/test_source_changed_dialog.py index 840b7e22e..ea68b13d4 100644 --- a/tests/ui_gtk/doceditor/test_source_changed_dialog.py +++ b/tests/ui_gtk/doceditor/test_source_changed_dialog.py @@ -6,12 +6,18 @@ import pytest +from rayforge.context import get_context from rayforge.core.source_asset import SourceAsset from rayforge.image.svg.renderer import SVG_RENDERER +from rayforge.ui_gtk.canvas2d import context_menu from rayforge.ui_gtk.doceditor.source_changed_dialog import ( SourceChangedDialog, ) from rayforge.ui_gtk.doceditor.source_reload import SourceReloadMonitor +from rayforge.ui_gtk.main_menu import MainMenu +from rayforge.ui_gtk.settings.general_preferences_page import ( + GeneralPreferencesPage, +) pytestmark = pytest.mark.ui @@ -83,7 +89,11 @@ def monitor(): editor = MagicMock() watcher = MagicMock() monitor = SourceReloadMonitor( - MagicMock(), editor, watcher=watcher, dialog_factory=_FakeDialog + MagicMock(), + editor, + watcher=watcher, + dialog_factory=_FakeDialog, + enabled=lambda: True, ) return monitor, editor, watcher @@ -147,3 +157,88 @@ def test_check_now_uses_immediate_check(self, monitor): mon.check_now() assert _FakeDialog.instances[0].assets == [asset] + + +class TestMonitorPreferenceAndActions: + def test_disabled_watching_does_nothing(self): + _FakeDialog.instances = [] + watcher = MagicMock() + mon = SourceReloadMonitor( + MagicMock(), + MagicMock(), + watcher=watcher, + dialog_factory=_FakeDialog, + enabled=lambda: False, + ) + + assert mon.tick() is True + mon.check_now() + + watcher.poll.assert_not_called() + watcher.check_now.assert_not_called() + assert _FakeDialog.instances == [] + + def test_reload_assets_reloads_and_acknowledges(self, monitor): + mon, editor, watcher = monitor + a, b = _asset("a.svg"), _asset("b.svg") + + mon.reload_assets([a, b]) + + assert editor.reload.reload_source.call_count == 2 + watcher.acknowledge.assert_any_call(a) + watcher.acknowledge.assert_any_call(b) + + def test_relink_choice_relinks_and_acknowledges(self, monitor): + mon, editor, watcher = monitor + asset = _asset("a.svg") + path = Path("/tmp/elsewhere/a.svg") + + mon.relink_to(asset, path) + + editor.reload.relink_source.assert_called_once_with(asset, path) + watcher.acknowledge.assert_called_once_with(asset) + + +class TestMenus: + def _actions(self, model) -> set[str]: + found = set() + for i in range(model.get_n_items()): + action = model.get_item_attribute_value(i, "action", None) + if action is not None: + found.add(action.get_string()) + for link in ("section", "submenu"): + sub = model.get_item_link(i, link) + if sub is not None: + found |= self._actions(sub) + return found + + def test_canvas_item_menu_offers_reload_and_relink(self): + actions = self._actions(context_menu._MENU_MODELS["item"]) + + assert {"win.reload-source", "win.relink-source"} <= actions + + def test_file_menu_offers_reload_and_relink(self): + actions = self._actions(MainMenu()) + + assert {"win.reload-source", "win.relink-source"} <= actions + + +class TestWatchPreference: + def test_switch_reflects_and_updates_config(self, context_initializer): + config = get_context().config + page = GeneralPreferencesPage() + assert page.watch_sources_row.get_active() is True + + page.watch_sources_row.set_active(False) + + assert config.watch_source_files is False + + def test_default_monitor_follows_config(self, context_initializer): + watcher = MagicMock() + watcher.poll.return_value = [] + mon = SourceReloadMonitor(MagicMock(), MagicMock(), watcher=watcher) + get_context().config.set_watch_source_files(False) + + mon.tick() + + watcher.poll.assert_not_called() From a50654618b4412d938a01409b8e66c6fce4bd019 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Fri, 9 Oct 2026 17:51:39 +0200 Subject: [PATCH 019/104] feat: report where dropped tabs were on reload Refs #513 --- rayforge/doceditor/reload_cmd.py | 122 ++++++++++++++++++++++------- tests/doceditor/test_reload_cmd.py | 67 ++++++++++++++++ 2 files changed, 159 insertions(+), 30 deletions(-) diff --git a/rayforge/doceditor/reload_cmd.py b/rayforge/doceditor/reload_cmd.py index 1f8788cbc..4423d3557 100644 --- a/rayforge/doceditor/reload_cmd.py +++ b/rayforge/doceditor/reload_cmd.py @@ -35,6 +35,7 @@ logger = logging.getLogger(__name__) TAB_TOLERANCE_MM = 1.0 +MAX_LISTED_TABS = 5 _SOURCE_DATA_FIELDS = ( "original_data", @@ -49,6 +50,21 @@ ) +@dataclass +class DroppedTab: + """A tab that no longer fits the reloaded outline.""" + + workpiece_name: str + x_mm: float + y_mm: float + distance_mm: float + + def describe(self) -> str: + return _("{name} ({x:.1f}, {y:.1f} mm)").format( + name=self.workpiece_name, x=self.x_mm, y=self.y_mm + ) + + @dataclass class ReloadReport: """Summary of what a reload changed in the document.""" @@ -58,9 +74,13 @@ class ReloadReport: added: int = 0 removed: int = 0 skipped: int = 0 - tabs_dropped: int = 0 + dropped_tabs: list[DroppedTab] = field(default_factory=list) error: str | None = None + @property + def tabs_dropped(self) -> int: + return len(self.dropped_tabs) + def describe(self) -> str: if self.error: return _("Could not reload {name}: {error}").format( @@ -93,16 +113,25 @@ def describe(self) -> str: self.skipped, ).format(n=self.skipped) ) - if self.tabs_dropped: - parts.append( - ngettext( - "{n} tab no longer fits and was removed", - "{n} tabs no longer fit and were removed", - self.tabs_dropped, - ).format(n=self.tabs_dropped) - ) + if self.dropped_tabs: + parts.append(self._describe_dropped_tabs()) return "; ".join(parts) + def _describe_dropped_tabs(self) -> str: + count = len(self.dropped_tabs) + places = ", ".join( + tab.describe() for tab in self.dropped_tabs[:MAX_LISTED_TABS] + ) + if count > MAX_LISTED_TABS: + places += " " + _("and {n} more").format(n=count - MAX_LISTED_TABS) + return ngettext( + "{n} tab more than {limit:.1f} mm from the new outline was " + "removed: {places}", + "{n} tabs more than {limit:.1f} mm from the new outline were " + "removed: {places}", + count, + ).format(n=count, limit=TAB_TOLERANCE_MM, places=places) + class ReplaceSourceDataCommand(Command): """Swaps the imported data of a SourceAsset, keeping its identity.""" @@ -239,6 +268,19 @@ def _anchor_shift(old: ImportResult, new: ImportResult) -> Matrix: return Matrix.translation(0.0, shift) +def _dropped_tab( + wp: WorkPiece, + boundaries: Geometry, + tab: Tab, + world: Matrix, + distance: float, +) -> DroppedTab: + point = boundaries.get_point_at(tab.segment_index, tab.pos) + local = (point[0], point[1]) if point is not None else (0.0, 0.0) + x, y = world.transform_point(local) + return DroppedTab(wp.name, x, y, distance) + + def _local_geometry(boundaries: Geometry, matrix: Matrix) -> Geometry: geo = boundaries.copy() geo.transform(matrix) @@ -503,7 +545,13 @@ def _new_state( matrix = delta @ fresh.matrix segment = self._adopt_segment(fresh, asset, spec, wp.source_segment) tabs, dropped = self._remap_tabs(wp, fresh, matrix) - report.tabs_dropped += dropped + for tab in dropped: + logger.info( + f"Reload dropped a tab of {tab.workpiece_name} at " + f"({tab.x_mm:.2f}, {tab.y_mm:.2f}) mm, " + f"{tab.distance_mm:.2f} mm from the new outline" + ) + report.dropped_tabs.extend(dropped) return _WorkPieceState( segment=segment, matrix=matrix, @@ -514,39 +562,53 @@ def _new_state( @staticmethod def _remap_tabs( wp: WorkPiece, fresh: WorkPiece, matrix: Matrix - ) -> tuple[list[Tab], int]: + ) -> tuple[list[Tab], list[DroppedTab]]: """ Moves each tab to the closest point of the new outline, measured in the workpiece's parent space. Tabs whose outline moved away are - dropped. + dropped and reported with their former world position. """ if not wp.tabs: - return [], 0 + return [], [] old_boundaries = wp.boundaries new_boundaries = fresh.boundaries + world = wp.get_world_transform() if old_boundaries is None or new_boundaries is None: - return [], len(wp.tabs) + x, y = world.transform_point((0.0, 0.0)) + return [], [ + DroppedTab(wp.name, x, y, float("inf")) for _tab in wp.tabs + ] old_geo = _local_geometry(old_boundaries, wp.matrix) new_geo = _local_geometry(new_boundaries, matrix) - kept = [] + kept: list[Tab] = [] + dropped: list[DroppedTab] = [] for tab in wp.tabs: point = old_geo.get_point_at(tab.segment_index, tab.pos) - if point is None: - continue - closest = new_geo.find_closest_point(point[0], point[1]) - if closest is None: - continue - index, t, new_point = closest - distance = ( - (new_point[0] - point[0]) ** 2 + (new_point[1] - point[1]) ** 2 - ) ** 0.5 - if distance <= TAB_TOLERANCE_MM: - kept.append( - replace( - tab, segment_index=index, pos=min(1.0, max(0.0, t)) + closest = ( + new_geo.find_closest_point(point[0], point[1]) + if point is not None + else None + ) + distance = float("inf") + if point is not None and closest is not None: + index, t, new_point = closest + distance = ( + (new_point[0] - point[0]) ** 2 + + (new_point[1] - point[1]) ** 2 + ) ** 0.5 + if distance <= TAB_TOLERANCE_MM: + kept.append( + replace( + tab, + segment_index=index, + pos=min(1.0, max(0.0, t)), + ) ) - ) - return kept, len(wp.tabs) - len(kept) + continue + dropped.append( + _dropped_tab(wp, old_boundaries, tab, world, distance) + ) + return kept, dropped def _plan_addition( self, diff --git a/tests/doceditor/test_reload_cmd.py b/tests/doceditor/test_reload_cmd.py index f72df997a..ddb1c1a0d 100644 --- a/tests/doceditor/test_reload_cmd.py +++ b/tests/doceditor/test_reload_cmd.py @@ -19,6 +19,12 @@ ) from rayforge.core.workpiece import WorkPiece from rayforge.doceditor.editor import DocEditor +from rayforge.doceditor.reload_cmd import ( + MAX_LISTED_TABS, + TAB_TOLERANCE_MM, + DroppedTab, + ReloadReport, +) from rayforge.image import importer_registry from rayforge.shared.tasker.manager import TaskManager @@ -291,6 +297,49 @@ def test_tab_on_moved_outline_is_dropped(self, editor, tmp_path): assert report.tabs_dropped == 1 assert wp.tabs == [] + def test_dropped_tab_reports_where_it_was(self, editor, tmp_path): + path = _write(tmp_path / "a.svg", PAGE_80, RED_RECT, BLUE_CIRCLE) + asset = _import(editor, path, _flatten_spec()) + (wp,) = _workpieces_of(editor.doc, asset) + wp.name = "Lid" + _transform(wp, Matrix.translation(5, 7)) + assert wp.boundaries is not None + closest = wp.boundaries.find_closest_point(0.0, 1.0) + assert closest is not None + tab = Tab(width=3.0, segment_index=closest[0], pos=closest[1]) + wp.tabs = [tab] + x, y = _tab_world_point(wp, tab) + + moved = RED_RECT.replace('x="10"', 'x="70"').replace( + 'y="10"', 'y="60"' + ) + _write(path, PAGE_80, moved, BLUE_CIRCLE) + report = editor.reload.reload_source(asset) + + (dropped,) = report.dropped_tabs + assert dropped.workpiece_name == "Lid" + assert dropped.x_mm == pytest.approx(x, abs=1e-3) + assert dropped.y_mm == pytest.approx(y, abs=1e-3) + assert dropped.distance_mm > TAB_TOLERANCE_MM + message = report.describe() + assert f"Lid ({x:.1f}, {y:.1f} mm)" in message + assert f"{TAB_TOLERANCE_MM:.1f} mm" in message + + def test_kept_tabs_are_not_reported(self, editor, tmp_path): + path = _write(tmp_path / "a.svg", PAGE_80, RED_RECT, BLUE_CIRCLE) + asset = _import(editor, path, _flatten_spec()) + (wp,) = _workpieces_of(editor.doc, asset) + assert wp.boundaries is not None + closest = wp.boundaries.find_closest_point(0.0, 1.0) + assert closest is not None + wp.tabs = [Tab(width=3.0, segment_index=closest[0], pos=closest[1])] + + _write(path, PAGE_80, RED_RECT, BLUE_CIRCLE, GREEN_SQUARE) + report = editor.reload.reload_source(asset) + + assert report.dropped_tabs == [] + assert "tab" not in report.describe() + def test_split_pieces_are_left_alone(self, editor, tmp_path): path = _write(tmp_path / "a.svg", PAGE_80, RED_RECT, BLUE_CIRCLE) asset = _import(editor, path, _flatten_spec()) @@ -591,3 +640,21 @@ def test_reloadable_needs_existing_file(self, editor, tmp_path): assert not editor.reload.can_reload(asset) assert editor.reload.can_relink(asset) + + +class TestReportText: + def test_long_tab_lists_are_shortened(self): + report = ReloadReport( + source_name="a.svg", + dropped_tabs=[ + DroppedTab(f"Part {i}", float(i), 2.0, 3.0) + for i in range(MAX_LISTED_TABS + 2) + ], + ) + + message = report.describe() + + assert f"{MAX_LISTED_TABS + 2} tabs" in message + assert f"Part {MAX_LISTED_TABS - 1} (" in message + assert f"Part {MAX_LISTED_TABS} (" not in message + assert "and 2 more" in message From 71e79a28dd39983a8a7ddadccdda988aa2f6da8a Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Fri, 9 Oct 2026 18:44:00 +0200 Subject: [PATCH 020/104] fix: type SourceReloadMonitor parent as Gtk.Window The relink dialog expects a Gtk.Window; the caller passes the main window, so the annotation was too narrow for pyright. Refs #513 --- rayforge/ui_gtk/doceditor/source_reload.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rayforge/ui_gtk/doceditor/source_reload.py b/rayforge/ui_gtk/doceditor/source_reload.py index 2ab7fd2de..bb9a20eea 100644 --- a/rayforge/ui_gtk/doceditor/source_reload.py +++ b/rayforge/ui_gtk/doceditor/source_reload.py @@ -34,7 +34,7 @@ class SourceReloadMonitor: def __init__( self, - parent: Gtk.Widget, + parent: Gtk.Window, editor: DocEditor, watcher: SourceWatcher | None = None, dialog_factory: Callable[..., Any] = SourceChangedDialog, From a60df616c8a32ce62a9921b50a570dcd3cb83f5c Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Fri, 9 Oct 2026 12:46:48 +0200 Subject: [PATCH 021/104] fix(ui): show enum-backed step values in their choice rows The step settings pages pushed raw attribute values into the varset rows. For enum-backed attributes such as EngraveStep.dither_algorithm the combo adapter got "DitherAlgorithm.BAYER4", matched no entry and kept showing the first one (Floyd Steinberg), both when the dialog opened and after undo or an external change. Serialize the values with Step.recipe_value(), the same way recipes store them, before handing them to the rows. --- CHANGELOG.md | 3 ++ .../tests/ui_gtk/test_laser_pages.py | 22 ++++++++++++ .../doceditor/step_settings/pages/base.py | 34 +++++++++---------- 3 files changed, 42 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 283c89fe6..b72239885 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Step settings: choice rows backed by an enum (such as the Engrave + step's dithering algorithm) now show the step's actual value when + the dialog opens and after undo, instead of the first entry - Ruida: the setup wizard no longer requires the optional USB device and Magic fields, so Next is enabled again once a valid hostname is entered; the Magic field is prefilled with the standard 0x88 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 c5c44e9bd..3abb0501e 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 @@ -19,6 +19,7 @@ from laser_essentials.widgets.raster_power_widget import RasterPowerWidget from rayforge.core.step_registry import step_registry +from rayforge.image.dither import DitherAlgorithm from rayforge.pipeline.stage.assembler_helpers import DepthMode from rayforge.ui_gtk.doceditor.step_settings.dialog import StepSettingsDialog from rayforge.ui_gtk.doceditor.step_settings.pages import StepSettingsPage @@ -461,6 +462,27 @@ def test_raster_page_mode_visibility(editor, laser_machine, ui_context): assert sample_interval.get_visible() is True +@pytest.mark.ui +def test_raster_page_shows_the_steps_dither_algorithm( + editor, laser_machine, ui_context +): + """The step stores the algorithm as an enum; the combo row must + still select it, on open and after an external change.""" + step_cls = step_registry.get("EngraveStep") + assert step_cls is not None + step = cast(EngraveStep, step_cls.create(ui_context)) + step.depth_mode = "DITHER" + step.dither_algorithm = DitherAlgorithm.BAYER4 + page = RasterSettingsPage(editor, step) + + adapter = page.engrave_widget.adapter_for("dither_algorithm") + assert adapter is not None + assert adapter.get_value() == "BAYER4" + + step.set_dither_algorithm(DitherAlgorithm.STUCKI) + assert adapter.get_value() == "STUCKI" + + def _depth_mode_adapter(page: RasterSettingsPage) -> ComboAdapter: adapter = page.engrave_widget.adapter_for("depth_mode") assert isinstance(adapter, ComboAdapter) diff --git a/rayforge/ui_gtk/doceditor/step_settings/pages/base.py b/rayforge/ui_gtk/doceditor/step_settings/pages/base.py index e0b1cd0b2..820566fc0 100644 --- a/rayforge/ui_gtk/doceditor/step_settings/pages/base.py +++ b/rayforge/ui_gtk/doceditor/step_settings/pages/base.py @@ -86,10 +86,7 @@ def _on_recipe_applied(self, *args): # edits and push the applied values into the rows. for widget, var_set in self._varset_widgets: widget.cancel_pending() - values = { - var.key: getattr(self.step, var.key, None) for var in var_set - } - widget.set_values(values) + widget.set_values(self._model_values(var_set)) self._update_machine_bounds() def get_machine(self) -> "Machine | None": @@ -170,9 +167,7 @@ def add_varset_section( if description: widget.set_description(escape_title(description)) widget.populate(var_set) - widget.set_values( - {var.key: getattr(self.step, var.key, None) for var in var_set} - ) + widget.set_values(self._model_values(var_set)) widget.data_changed.connect(self._on_varset_data_changed) self.add(widget) self._sections.append(widget) @@ -195,6 +190,19 @@ def _varset_for_keys( ) return VarSet(vars=[var for var in var_set if var.key in keys]) + def _model_values(self, var_set: VarSet) -> dict[str, Any]: + """The step's current values for the rows of ``var_set``. + + Enum-backed attributes are serialized the same way recipes + store them, so choice rows can match them to their entries. + """ + return { + var.key: self.step.recipe_value( + var.key, getattr(self.step, var.key, None) + ) + for var in var_set + } + def _on_varset_data_changed(self, widget: VarSetWidget, key: str): value = widget.get_values().get(key) self.set_step_property(key, value) @@ -207,10 +215,7 @@ def _sync_widgets_to_model(self, *args): :meth:`_on_recipe_applied`. """ for widget, var_set in self._varset_widgets: - values = { - var.key: getattr(self.step, var.key, None) for var in var_set - } - widget.sync_from_model(values) + widget.sync_from_model(self._model_values(var_set)) self._update_machine_bounds() def _on_config_changed(self, *args): @@ -227,12 +232,7 @@ def _on_config_changed(self, *args): if new_var_set is None: continue widget.populate(new_var_set) - widget.set_values( - { - var.key: getattr(self.step, var.key, None) - for var in new_var_set - } - ) + widget.set_values(self._model_values(new_var_set)) self._varset_widgets[i] = (widget, new_var_set) self._on_machine_changed() From 6f5f647abee0ab70fd542327fe7a6a3962e4f056 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Fri, 9 Oct 2026 12:46:55 +0200 Subject: [PATCH 022/104] feat(image): add error-diffusion kernels and halftone screens Extend DitherAlgorithm with Atkinson, Stucki, Jarvis-Judice-Ninke, Sierra (3-row, 2-row, Lite) and Burkes error diffusion, a clustered-dot Newsprint screen and a round-dot Halftone screen. - apply_error_diffusion() takes any forward kernel and an optional serpentine scan. The error within a row is propagated per pixel, the error for the following rows with one vectorised add per kernel tap, which dithers a 2.5 MP image in about 0.6 s. Like raygeo's Floyd-Steinberg it works in linear light. - apply_halftone() lays a cosine spot screen out in millimetres, so the dots stay round on the non-square raster pixels, and ranks the spot values so the dot area follows the darkness. - Newsprint tiles a 45 degree clustered-dot matrix whose cells scale with the minimum feature size and stay square in millimetres. Floyd-Steinberg without serpentine and the Bayer screens keep using raygeo, so existing jobs produce identical output. Refs #514 --- rayforge/image/dither.py | 344 +++++++++++++++++++++++- tests/image/test_dither_algorithms.py | 363 ++++++++++++++++++++++++++ 2 files changed, 699 insertions(+), 8 deletions(-) create mode 100644 tests/image/test_dither_algorithms.py diff --git a/rayforge/image/dither.py b/rayforge/image/dither.py index 1a0d64881..83897fb08 100644 --- a/rayforge/image/dither.py +++ b/rayforge/image/dither.py @@ -1,5 +1,6 @@ """Dithering algorithms for converting grayscale images to binary.""" +import math from enum import Enum from gettext import gettext as _ @@ -10,24 +11,145 @@ apply_floyd_steinberg_dither, apply_minimum_run_length, ) +from raygeo.image.srgb import srgb_to_linear + +DEFAULT_HALFTONE_CELL_MM = 0.5 +DEFAULT_HALFTONE_ANGLE = 45.0 + +#: Rows processed per block when building the halftone screen, to +#: bound the memory of the per-pixel coordinate arrays. +_HALFTONE_BLOCK_ROWS = 256 class DitherAlgorithm(Enum): FLOYD_STEINBERG = "floyd_steinberg" + ATKINSON = "atkinson" + STUCKI = "stucki" + JARVIS_JUDICE_NINKE = "jarvis" + SIERRA = "sierra" + SIERRA_2ROW = "sierra_2row" + SIERRA_LITE = "sierra_lite" + BURKES = "burkes" BAYER2 = "bayer2" BAYER4 = "bayer4" BAYER8 = "bayer8" + NEWSPRINT = "newsprint" + HALFTONE = "halftone" @property def display_name(self) -> str: names = { self.FLOYD_STEINBERG: _("Floyd Steinberg"), + self.ATKINSON: _("Atkinson"), + self.STUCKI: _("Stucki"), + self.JARVIS_JUDICE_NINKE: _("Jarvis, Judice & Ninke"), + self.SIERRA: _("Sierra"), + self.SIERRA_2ROW: _("Sierra 2-Row"), + self.SIERRA_LITE: _("Sierra Lite"), + self.BURKES: _("Burkes"), self.BAYER2: _("Bayer 2"), self.BAYER4: _("Bayer 4"), self.BAYER8: _("Bayer 8"), + self.NEWSPRINT: _("Newsprint"), + self.HALFTONE: _("Halftone"), } return names[self] + @property + def is_error_diffusion(self) -> bool: + return self in ERROR_DIFFUSION_KERNELS + + +#: Error-diffusion kernels as ``(divisor, [(dx, dy, weight), ...])``. +#: ``dx`` is relative to the scan direction, so serpentine rows mirror +#: it. Atkinson deliberately diffuses only 6/8 of the error. +ERROR_DIFFUSION_KERNELS: dict[ + DitherAlgorithm, tuple[int, list[tuple[int, int, int]]] +] = { + DitherAlgorithm.FLOYD_STEINBERG: ( + 16, + [(1, 0, 7), (-1, 1, 3), (0, 1, 5), (1, 1, 1)], + ), + DitherAlgorithm.ATKINSON: ( + 8, + [(1, 0, 1), (2, 0, 1), (-1, 1, 1), (0, 1, 1), (1, 1, 1), (0, 2, 1)], + ), + DitherAlgorithm.STUCKI: ( + 42, + [ + (1, 0, 8), + (2, 0, 4), + (-2, 1, 2), + (-1, 1, 4), + (0, 1, 8), + (1, 1, 4), + (2, 1, 2), + (-2, 2, 1), + (-1, 2, 2), + (0, 2, 4), + (1, 2, 2), + (2, 2, 1), + ], + ), + DitherAlgorithm.JARVIS_JUDICE_NINKE: ( + 48, + [ + (1, 0, 7), + (2, 0, 5), + (-2, 1, 3), + (-1, 1, 5), + (0, 1, 7), + (1, 1, 5), + (2, 1, 3), + (-2, 2, 1), + (-1, 2, 3), + (0, 2, 5), + (1, 2, 3), + (2, 2, 1), + ], + ), + DitherAlgorithm.SIERRA: ( + 32, + [ + (1, 0, 5), + (2, 0, 3), + (-2, 1, 2), + (-1, 1, 4), + (0, 1, 5), + (1, 1, 4), + (2, 1, 2), + (-1, 2, 2), + (0, 2, 3), + (1, 2, 2), + ], + ), + DitherAlgorithm.SIERRA_2ROW: ( + 16, + [ + (1, 0, 4), + (2, 0, 3), + (-2, 1, 1), + (-1, 1, 2), + (0, 1, 3), + (1, 1, 2), + (2, 1, 1), + ], + ), + DitherAlgorithm.SIERRA_LITE: (4, [(1, 0, 2), (-1, 1, 1), (0, 1, 1)]), + DitherAlgorithm.BURKES: ( + 32, + [ + (1, 0, 8), + (2, 0, 4), + (-2, 1, 2), + (-1, 1, 4), + (0, 1, 8), + (1, 1, 4), + (2, 1, 2), + ], + ), +} + BAYER_MATRICES = { DitherAlgorithm.BAYER2: np.array([[0, 2], [3, 1]], dtype=np.float32), @@ -51,11 +173,201 @@ def display_name(self) -> str: } +def _spot_function(u: np.ndarray, v: np.ndarray) -> np.ndarray: + """Round-dot screen spot function in cell units, 1 at dot centres + and 0 halfway between them.""" + return (np.cos(2.0 * np.pi * u) + np.cos(2.0 * np.pi * v)) / 4.0 + 0.5 + + +def _build_spot_cdf(samples: int = 256) -> np.ndarray: + """Sorted spot values over one cell, used to turn a spot value + into the fraction of the cell it covers.""" + grid = (np.arange(samples) + 0.5) / samples + u, v = np.meshgrid(grid, grid) + return np.sort(_spot_function(u, v).ravel()) + + +def _build_newsprint_matrix(size: int = 8) -> np.ndarray: + """Clustered-dot threshold ranks with the dots on a 45° lattice.""" + grid = (np.arange(size) + 0.5) / size + u, v = np.meshgrid(grid, grid) + spot = _spot_function(u + v, u - v) + order = np.argsort(-spot.ravel(), kind="stable") + ranks = np.empty(size * size, dtype=np.int32) + ranks[order] = np.arange(size * size) + return ranks.reshape(size, size) + + +_SPOT_CDF = _build_spot_cdf() +NEWSPRINT_MATRIX = _build_newsprint_matrix() + + +def _diffuse_row( + row: np.ndarray, + forward: tuple[float, float], + reverse: bool, +) -> tuple[np.ndarray, np.ndarray]: + """Quantise one row, pushing the error to the next one or two + pixels in scan direction. Returns ``(errors, dark)`` in image + order.""" + values = (row[::-1] if reverse else row).tolist() + width = len(values) + values.extend((0.0, 0.0)) + w1, w2 = forward + errors = [0.0] * width + dark = [0] * width + for x in range(width): + value = values[x] + if value < 0.5: + dark[x] = 1 + error = value + else: + error = value - 1.0 + errors[x] = error + values[x + 1] += error * w1 + values[x + 2] += error * w2 + errors_arr = np.asarray(errors) + dark_arr = np.asarray(dark, dtype=np.uint8) + if reverse: + return errors_arr[::-1], dark_arr[::-1] + return errors_arr, dark_arr + + +def apply_error_diffusion( + values: np.ndarray, + kernel: tuple[int, list[tuple[int, int, int]]], + serpentine: bool = False, +) -> np.ndarray: + """ + Error-diffusion dithering with an arbitrary forward kernel. + + The error within a row is propagated pixel by pixel; the error for + the following rows is spread with one vectorised add per kernel + tap once the row is done. + + Args: + values: 2D float array of intensities in [0, 1] (1 = white). + kernel: ``(divisor, [(dx, dy, weight), ...])`` with + ``dy == 0`` taps limited to ``dx`` 1 and 2. + serpentine: Scan odd rows right-to-left, mirroring the kernel. + + Returns: + Binary uint8 array where 1 marks pixels to engrave. + """ + height, width = values.shape + out = np.zeros((height, width), dtype=np.uint8) + if height == 0 or width == 0: + return out + divisor, taps = kernel + forward = [0.0, 0.0, 0.0] + later = [] + for dx, dy, weight in taps: + if dy == 0: + forward[dx] = weight / divisor + else: + later.append((dx, dy, weight / divisor)) + pad = max((abs(dx) for dx, _dy, _w in later), default=0) + extra_rows = max((dy for _dx, dy, _w in later), default=0) + work = np.zeros((height + extra_rows, width + 2 * pad)) + work[:height, pad : pad + width] = values + for y in range(height): + reverse = serpentine and y % 2 == 1 + errors, dark = _diffuse_row( + work[y, pad : pad + width], (forward[1], forward[2]), reverse + ) + out[y] = dark + for dx, dy, weight in later: + start = pad + (-dx if reverse else dx) + work[y + dy, start : start + width] += errors * weight + return out + + +def apply_halftone( + grayscale: np.ndarray, + cell_mm: float, + angle_deg: float, + pixels_per_mm: tuple[float, float], +) -> np.ndarray: + """ + Amplitude-modulated halftone screen with round dots. + + The screen is laid out in millimetres, so the dots stay round on + non-square pixels. Dot area tracks darkness: a pixel is engraved + when its brightness is below the share of the cell that the spot + function ranks at or under it. + + Args: + grayscale: 2D uint8 grayscale array (0-255). + cell_mm: Distance between dot centres in mm. + angle_deg: Screen angle in degrees. + pixels_per_mm: ``(x, y)`` image resolution. + + Returns: + Binary uint8 array where 1 marks pixels to engrave. + """ + height, width = grayscale.shape + ppm_x, ppm_y = pixels_per_mm + if cell_mm <= 0: + cell_mm = 1.0 / max(ppm_x, ppm_y) + theta = math.radians(angle_deg) + cos_t = math.cos(theta) / cell_mm + sin_t = math.sin(theta) / cell_mm + x_mm = (np.arange(width) + 0.5) / ppm_x + out = np.empty((height, width), dtype=np.uint8) + for top in range(0, height, _HALFTONE_BLOCK_ROWS): + bottom = min(height, top + _HALFTONE_BLOCK_ROWS) + y_mm = ((np.arange(top, bottom) + 0.5) / ppm_y)[:, None] + u = x_mm * cos_t + y_mm * sin_t + v = y_mm * cos_t - x_mm * sin_t + spot = _spot_function(u, v) + coverage = (np.searchsorted(_SPOT_CDF, spot) + 0.5) / _SPOT_CDF.size + brightness = grayscale[top:bottom] / 255.0 + out[top:bottom] = brightness < coverage + return out + + +def _apply_newsprint( + grayscale: np.ndarray, + min_feature_px: int, + pixels_per_mm: tuple[float, float], +) -> np.ndarray: + """Tile the clustered-dot matrix with physically square cells.""" + height, width = grayscale.shape + size = NEWSPRINT_MATRIX.shape[0] + ppm_x, ppm_y = pixels_per_mm + cell_x = max(1, min_feature_px) + cell_y = max(1, round(cell_x * ppm_y / ppm_x)) + rows = (np.arange(height) // cell_y) % size + cols = (np.arange(width) // cell_x) % size + ranks = NEWSPRINT_MATRIX[rows[:, None], cols[None, :]] + thresholds = 255.0 * (1.0 - (ranks + 0.5) / (size * size)) + return (grayscale < thresholds).astype(np.uint8) + + +def _error_diffusion_dither( + grayscale: np.ndarray, + dither_algorithm: DitherAlgorithm, + serpentine: bool, +) -> np.ndarray: + """Dither in linear light, like raygeo's Floyd-Steinberg.""" + linear = srgb_to_linear(np.ascontiguousarray(grayscale, dtype=np.uint8)) + return apply_error_diffusion( + linear.astype(np.float64), + ERROR_DIFFUSION_KERNELS[dither_algorithm], + serpentine, + ) + + def grayscale_to_dithered_array( grayscale: np.ndarray, dither_algorithm: DitherAlgorithm, invert: bool = False, min_feature_px: int = 1, + *, + serpentine: bool = False, + halftone_cell_mm: float = DEFAULT_HALFTONE_CELL_MM, + halftone_angle: float = DEFAULT_HALFTONE_ANGLE, + pixels_per_mm: tuple[float, float] = (1.0, 1.0), ) -> np.ndarray: """ Convert a grayscale array to a dithered binary array. @@ -65,20 +377,36 @@ def grayscale_to_dithered_array( dither_algorithm: The dithering algorithm to use. invert: If True, invert the output (engrave light areas). min_feature_px: Minimum feature size in pixels. + serpentine: Alternate the scan direction per row + (error-diffusion algorithms only). + halftone_cell_mm: Halftone dot spacing in mm. + halftone_angle: Halftone screen angle in degrees. + pixels_per_mm: ``(x, y)`` image resolution, used by the + screens to keep their cells square in millimetres. Returns: Binary image where 1 represents areas to engrave. """ - if dither_algorithm == DitherAlgorithm.FLOYD_STEINBERG: + if dither_algorithm == DitherAlgorithm.FLOYD_STEINBERG and not serpentine: bw_image = apply_floyd_steinberg_dither(grayscale, invert) - bw_image = apply_minimum_run_length(bw_image, min_feature_px) - else: - bayer_matrix = BAYER_MATRICES[dither_algorithm] - bw_image = apply_bayer_dither( - grayscale, bayer_matrix, invert, cell_size=min_feature_px + return apply_minimum_run_length(bw_image, min_feature_px) + if dither_algorithm in BAYER_MATRICES: + return apply_bayer_dither( + grayscale, + BAYER_MATRICES[dither_algorithm], + invert, + cell_size=min_feature_px, ) - - return bw_image + if invert: + grayscale = 255 - grayscale + if dither_algorithm == DitherAlgorithm.HALFTONE: + return apply_halftone( + grayscale, halftone_cell_mm, halftone_angle, pixels_per_mm + ) + if dither_algorithm == DitherAlgorithm.NEWSPRINT: + return _apply_newsprint(grayscale, min_feature_px, pixels_per_mm) + bw_image = _error_diffusion_dither(grayscale, dither_algorithm, serpentine) + return apply_minimum_run_length(bw_image, min_feature_px) def surface_to_dithered_array( diff --git a/tests/image/test_dither_algorithms.py b/tests/image/test_dither_algorithms.py new file mode 100644 index 000000000..73991d0c8 --- /dev/null +++ b/tests/image/test_dither_algorithms.py @@ -0,0 +1,363 @@ +"""Tests for the error-diffusion and screen dithering algorithms.""" + +from typing import cast + +import numpy as np +import pytest +from raygeo.image.dither import ( + apply_floyd_steinberg_dither, + apply_minimum_run_length, +) +from scipy import ndimage + +from rayforge.image.dither import ( + ERROR_DIFFUSION_KERNELS, + DitherAlgorithm, + apply_error_diffusion, + apply_halftone, + grayscale_to_dithered_array, + srgb_to_linear, +) + +ERROR_DIFFUSION = [ + DitherAlgorithm.FLOYD_STEINBERG, + DitherAlgorithm.ATKINSON, + DitherAlgorithm.STUCKI, + DitherAlgorithm.JARVIS_JUDICE_NINKE, + DitherAlgorithm.SIERRA, + DitherAlgorithm.SIERRA_2ROW, + DitherAlgorithm.SIERRA_LITE, + DitherAlgorithm.BURKES, +] + +BAYER = [ + DitherAlgorithm.BAYER2, + DitherAlgorithm.BAYER4, + DitherAlgorithm.BAYER8, +] + +NEW_ALGORITHMS = [ + a + for a in DitherAlgorithm + if a is not DitherAlgorithm.FLOYD_STEINBERG and a not in BAYER +] + + +def _reference_diffusion(values, kernel, serpentine): + """Straightforward per-pixel error diffusion used as an oracle.""" + divisor, taps = kernel + work = values.astype(np.float64).copy() + height, width = work.shape + out = np.zeros((height, width), dtype=np.uint8) + for y in range(height): + reverse = serpentine and y % 2 == 1 + xs = range(width - 1, -1, -1) if reverse else range(width) + for x in xs: + old = work[y, x] + new = 0.0 if old < 0.5 else 1.0 + out[y, x] = 1 if new == 0.0 else 0 + err = old - new + for dx, dy, weight in taps: + tx = x - dx if reverse else x + dx + ty = y + dy + if 0 <= tx < width and ty < height: + work[ty, tx] += err * weight / divisor + return out + + +def _label(image: np.ndarray) -> tuple[np.ndarray, int]: + """Connected dots of a binary image and their count.""" + return cast(tuple[np.ndarray, int], ndimage.label(image)) + + +def _dyadic_image(rng, shape): + """Values k/16 keep every diffused error exactly representable.""" + return rng.integers(0, 17, shape).astype(np.float64) / 16.0 + + +class TestAlgorithmCatalogue: + def test_new_algorithms_exist_with_stable_values(self): + assert DitherAlgorithm.ATKINSON.value == "atkinson" + assert DitherAlgorithm.STUCKI.value == "stucki" + assert DitherAlgorithm.JARVIS_JUDICE_NINKE.value == "jarvis" + assert DitherAlgorithm.SIERRA.value == "sierra" + assert DitherAlgorithm.SIERRA_2ROW.value == "sierra_2row" + assert DitherAlgorithm.SIERRA_LITE.value == "sierra_lite" + assert DitherAlgorithm.BURKES.value == "burkes" + assert DitherAlgorithm.NEWSPRINT.value == "newsprint" + assert DitherAlgorithm.HALFTONE.value == "halftone" + + def test_every_algorithm_has_a_display_name(self): + for algo in DitherAlgorithm: + assert algo.display_name + + def test_error_diffusion_flag(self): + for algo in DitherAlgorithm: + assert algo.is_error_diffusion == (algo in ERROR_DIFFUSION) + + @pytest.mark.parametrize("algo", ERROR_DIFFUSION) + def test_kernel_weights_sum_to_divisor(self, algo): + divisor, taps = ERROR_DIFFUSION_KERNELS[algo] + total = sum(weight for _dx, _dy, weight in taps) + if algo is DitherAlgorithm.ATKINSON: + assert total == 6 and divisor == 8 + else: + assert total == divisor + + @pytest.mark.parametrize("algo", ERROR_DIFFUSION) + def test_kernels_only_push_error_forward(self, algo): + _divisor, taps = ERROR_DIFFUSION_KERNELS[algo] + for dx, dy, _weight in taps: + assert dy > 0 or (dy == 0 and dx > 0) + + def test_stucki_kernel(self): + divisor, taps = ERROR_DIFFUSION_KERNELS[DitherAlgorithm.STUCKI] + assert divisor == 42 + assert sorted(taps) == sorted( + [ + (1, 0, 8), + (2, 0, 4), + (-2, 1, 2), + (-1, 1, 4), + (0, 1, 8), + (1, 1, 4), + (2, 1, 2), + (-2, 2, 1), + (-1, 2, 2), + (0, 2, 4), + (1, 2, 2), + (2, 2, 1), + ] + ) + + +class TestErrorDiffusionCore: + @pytest.mark.parametrize("algo", ERROR_DIFFUSION) + @pytest.mark.parametrize("serpentine", [False, True]) + def test_matches_reference_implementation(self, algo, serpentine): + rng = np.random.default_rng(7) + kernel = ERROR_DIFFUSION_KERNELS[algo] + for shape in [(1, 1), (1, 6), (5, 1), (4, 6), (6, 5)]: + values = _dyadic_image(rng, shape) + expected = _reference_diffusion(values, kernel, serpentine) + result = apply_error_diffusion(values, kernel, serpentine) + np.testing.assert_array_equal(result, expected) + + def test_serpentine_reverses_odd_rows(self): + kernel = ERROR_DIFFUSION_KERNELS[DitherAlgorithm.SIERRA_LITE] + values = np.tile(np.linspace(0.0, 1.0, 8), (4, 1)) + plain = apply_error_diffusion(values, kernel, False) + serpentine = apply_error_diffusion(values, kernel, True) + np.testing.assert_array_equal(plain[:2], serpentine[:2]) + assert not np.array_equal(plain, serpentine) + + def test_does_not_modify_input(self): + kernel = ERROR_DIFFUSION_KERNELS[DitherAlgorithm.STUCKI] + values = np.full((5, 5), 0.3) + original = values.copy() + apply_error_diffusion(values, kernel, True) + np.testing.assert_array_equal(values, original) + + def test_empty_image(self): + kernel = ERROR_DIFFUSION_KERNELS[DitherAlgorithm.STUCKI] + result = apply_error_diffusion(np.zeros((0, 4)), kernel, False) + assert result.shape == (0, 4) + + +class TestGrayscaleToDitheredArray: + @pytest.mark.parametrize("algo", NEW_ALGORITHMS) + def test_white_is_never_engraved(self, algo): + white = np.full((24, 24), 255, dtype=np.uint8) + result = grayscale_to_dithered_array(white, algo) + assert result.dtype == np.uint8 + assert result.shape == white.shape + assert not result.any() + + @pytest.mark.parametrize("algo", NEW_ALGORITHMS) + def test_black_is_fully_engraved(self, algo): + black = np.zeros((24, 24), dtype=np.uint8) + result = grayscale_to_dithered_array(black, algo) + assert result.all() + + @pytest.mark.parametrize("algo", NEW_ALGORITHMS) + def test_output_is_binary_and_deterministic(self, algo): + rng = np.random.default_rng(3) + img = rng.integers(0, 256, (31, 47)).astype(np.uint8) + first = grayscale_to_dithered_array(img, algo) + second = grayscale_to_dithered_array(img.copy(), algo) + assert set(np.unique(first)) <= {0, 1} + np.testing.assert_array_equal(first, second) + + @pytest.mark.parametrize("algo", NEW_ALGORITHMS) + def test_invert_engraves_the_complement(self, algo): + rng = np.random.default_rng(5) + img = rng.integers(0, 256, (20, 30)).astype(np.uint8) + inverted = grayscale_to_dithered_array(img, algo, invert=True) + expected = grayscale_to_dithered_array(255 - img, algo) + np.testing.assert_array_equal(inverted, expected) + + @pytest.mark.parametrize( + "algo", + [a for a in ERROR_DIFFUSION if a is not DitherAlgorithm.ATKINSON], + ) + @pytest.mark.parametrize("gray", [40, 128, 200]) + def test_error_diffusion_preserves_linear_tone(self, algo, gray): + img = np.full((64, 96), gray, dtype=np.uint8) + result = grayscale_to_dithered_array(img, algo) + expected = 1.0 - float( + srgb_to_linear(np.array([gray], dtype=np.uint8))[0] + ) + assert result.mean() == pytest.approx(expected, abs=0.02) + + def test_atkinson_loses_a_quarter_of_the_error(self): + """Atkinson only diffuses 6/8 of the error, which clips dark + and light tones and raises contrast compared to FS.""" + img = np.full((64, 64), 230, dtype=np.uint8) + atkinson = grayscale_to_dithered_array(img, DitherAlgorithm.ATKINSON) + stucki = grayscale_to_dithered_array(img, DitherAlgorithm.STUCKI) + assert atkinson.mean() < stucki.mean() + + def test_floyd_steinberg_without_serpentine_keeps_raygeo(self): + rng = np.random.default_rng(11) + img = rng.integers(0, 256, (40, 60)).astype(np.uint8) + result = grayscale_to_dithered_array( + img, DitherAlgorithm.FLOYD_STEINBERG, min_feature_px=2 + ) + expected = apply_minimum_run_length( + apply_floyd_steinberg_dither(img, False), 2 + ) + np.testing.assert_array_equal(result, expected) + + def test_floyd_steinberg_serpentine_differs_from_raygeo(self): + img = np.tile(np.linspace(0, 255, 64).astype(np.uint8), (32, 1)) + plain = grayscale_to_dithered_array( + img, DitherAlgorithm.FLOYD_STEINBERG + ) + serpentine = grayscale_to_dithered_array( + img, DitherAlgorithm.FLOYD_STEINBERG, serpentine=True + ) + assert not np.array_equal(plain, serpentine) + assert serpentine.mean() == pytest.approx(plain.mean(), abs=0.02) + + @pytest.mark.parametrize("algo", ERROR_DIFFUSION[1:]) + def test_minimum_feature_size_removes_short_runs(self, algo): + img = np.full((32, 64), 200, dtype=np.uint8) + result = grayscale_to_dithered_array(img, algo, min_feature_px=3) + for row in result: + padded = np.concatenate(([0], row, [0])) + edges = np.flatnonzero(np.diff(padded)) + runs = edges[1::2] - edges[::2] + assert (runs >= 3).all() + + def test_serpentine_is_ignored_by_screens(self): + img = np.tile(np.linspace(0, 255, 64).astype(np.uint8), (32, 1)) + for algo in (DitherAlgorithm.BAYER4, DitherAlgorithm.NEWSPRINT): + np.testing.assert_array_equal( + grayscale_to_dithered_array(img, algo), + grayscale_to_dithered_array(img, algo, serpentine=True), + ) + + +class TestHalftone: + @pytest.mark.parametrize("gray", [30, 100, 160, 220]) + def test_coverage_follows_darkness(self, gray): + img = np.full((200, 200), gray, dtype=np.uint8) + result = apply_halftone( + img, cell_mm=1.0, angle_deg=45.0, pixels_per_mm=(10.0, 10.0) + ) + assert result.mean() == pytest.approx(1.0 - gray / 255.0, abs=0.03) + + def test_dot_count_follows_cell_size(self): + """At 0° the dot centres sit on whole cells from the origin, so + a 20 mm square holds 21 x 21 (partly clipped) dots at 1 mm.""" + img = np.full((200, 200), 230, dtype=np.uint8) + for cell_mm, expected in ((1.0, 441), (2.0, 121)): + result = apply_halftone( + img, + cell_mm=cell_mm, + angle_deg=0.0, + pixels_per_mm=(10.0, 10.0), + ) + _labels, count = _label(result) + assert count == pytest.approx(expected, rel=0.1) + + def test_dots_are_round_on_anisotropic_pixels(self): + """X is sampled twice as densely as Y; the dots must still be + round in millimetres, so twice as wide in pixels.""" + img = np.full((100, 200), 220, dtype=np.uint8) + result = apply_halftone( + img, cell_mm=2.0, angle_deg=0.0, pixels_per_mm=(20.0, 10.0) + ) + labels, _count = _label(result) + boxes = ndimage.find_objects(labels) + interior = [ + b + for b in boxes + if b[0].start > 0 + and b[1].start > 0 + and b[0].stop < img.shape[0] + and b[1].stop < img.shape[1] + ] + assert interior + for rows, cols in interior: + height = rows.stop - rows.start + width = cols.stop - cols.start + assert width == pytest.approx(2 * height, abs=2) + + def test_angle_rotates_the_screen(self): + img = np.full((120, 120), 200, dtype=np.uint8) + kwargs = {"cell_mm": 1.0, "pixels_per_mm": (10.0, 10.0)} + straight = apply_halftone(img, angle_deg=0.0, **kwargs) + rotated = apply_halftone(img, angle_deg=45.0, **kwargs) + assert not np.array_equal(straight, rotated) + assert rotated.mean() == pytest.approx(straight.mean(), abs=0.03) + + def test_invalid_cell_size_falls_back_to_one_pixel_cells(self): + img = np.full((10, 10), 0, dtype=np.uint8) + result = apply_halftone( + img, cell_mm=0.0, angle_deg=0.0, pixels_per_mm=(10.0, 10.0) + ) + assert result.all() + + def test_dispatch_through_grayscale_to_dithered_array(self): + img = np.full((100, 100), 128, dtype=np.uint8) + direct = apply_halftone( + img, cell_mm=0.8, angle_deg=30.0, pixels_per_mm=(10.0, 10.0) + ) + dispatched = grayscale_to_dithered_array( + img, + DitherAlgorithm.HALFTONE, + halftone_cell_mm=0.8, + halftone_angle=30.0, + pixels_per_mm=(10.0, 10.0), + ) + np.testing.assert_array_equal(direct, dispatched) + + +class TestNewsprint: + def test_coverage_follows_darkness(self): + img = np.full((64, 64), 128, dtype=np.uint8) + result = grayscale_to_dithered_array(img, DitherAlgorithm.NEWSPRINT) + assert result.mean() == pytest.approx(1.0 - 128 / 255.0, abs=0.03) + + def test_dots_are_clustered(self): + """A clustered-dot screen forms far fewer separate dots than a + dispersed (Bayer) screen at the same tone.""" + img = np.full((64, 64), 200, dtype=np.uint8) + news = grayscale_to_dithered_array(img, DitherAlgorithm.NEWSPRINT) + bayer = grayscale_to_dithered_array(img, DitherAlgorithm.BAYER8) + _l1, news_dots = _label(news) + _l2, bayer_dots = _label(bayer) + assert news_dots < bayer_dots / 2 + + def test_cell_follows_minimum_feature_size(self): + img = np.full((64, 64), 200, dtype=np.uint8) + fine = grayscale_to_dithered_array( + img, DitherAlgorithm.NEWSPRINT, min_feature_px=1 + ) + coarse = grayscale_to_dithered_array( + img, DitherAlgorithm.NEWSPRINT, min_feature_px=2 + ) + _l1, fine_dots = _label(fine) + _l2, coarse_dots = _label(coarse) + assert coarse_dots < fine_dots From ebe11ac1538e52890cdc2529240abd5432548408 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Fri, 9 Oct 2026 12:47:04 +0200 Subject: [PATCH 023/104] feat(engrave): expose serpentine and halftone settings The Engrave step's dither algorithm list now offers every DitherAlgorithm. New step settings, saved with the document, recipes and cache params: - Serpentine: alternate the scan direction per row, shown for the error-diffusion algorithms. - Halftone Cell Size and Halftone Angle, shown for Halftone. preprocess_raster_image() forwards them together with the Y resolution, so the screens can keep their cells square in millimetres. apply_import_settings() accepts the depth mode, threshold and dither settings so importers can set them. Refs #514 --- .../laser_essentials/steps/raster_step.py | 81 ++++++++++- .../laser_essentials/widgets/raster_page.py | 3 + .../locale/de/LC_MESSAGES/laser_essentials.po | 24 +++ .../locale/en/LC_MESSAGES/laser_essentials.po | 24 +++ .../locale/es/LC_MESSAGES/laser_essentials.po | 24 +++ .../locale/fr/LC_MESSAGES/laser_essentials.po | 24 +++ .../locale/hi/LC_MESSAGES/laser_essentials.po | 24 +++ .../locale/laser_essentials.pot | 24 +++ .../locale/pt/LC_MESSAGES/laser_essentials.po | 24 +++ .../locale/uk/LC_MESSAGES/laser_essentials.po | 24 +++ .../zh_CN/LC_MESSAGES/laser_essentials.po | 24 +++ .../tests/steps/test_raster_step.py | 137 ++++++++++++++++++ .../tests/ui_gtk/test_laser_pages.py | 57 +++++++- rayforge/pipeline/stage/assembler_helpers.py | 23 ++- .../pipeline/stage/test_assembler_helpers.py | 73 ++++++++++ 15 files changed, 587 insertions(+), 3 deletions(-) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/steps/raster_step.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/steps/raster_step.py index 2c028cbb6..1e1fced13 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/steps/raster_step.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/steps/raster_step.py @@ -27,7 +27,11 @@ SliderIntVar, VarSet, ) -from rayforge.image.dither import DitherAlgorithm +from rayforge.image.dither import ( + DEFAULT_HALFTONE_ANGLE, + DEFAULT_HALFTONE_CELL_MM, + DitherAlgorithm, +) from rayforge.machine.models.laser import LaserHead from rayforge.pipeline.stage.assembler_helpers import ( DepthMode, @@ -76,6 +80,22 @@ def is_dither(v): def is_multi_pass(v): return v.get("depth_mode") == "MULTI_PASS" + def dither_algorithm(v): + name = v.get("dither_algorithm") or "FLOYD_STEINBERG" + return DitherAlgorithm.__members__.get(name) + + def is_error_diffusion(v): + algo = dither_algorithm(v) + return ( + is_dither(v) and algo is not None and algo.is_error_diffusion + ) + + def is_halftone(v): + return ( + is_dither(v) + and dither_algorithm(v) is DitherAlgorithm.HALFTONE + ) + def uses_grayscale(v): return is_power(v) or is_multi_pass(v) @@ -114,6 +134,35 @@ def uses_levels(v): visible_when=is_dither, allow_none=False, ), + BoolVar( + key="dither_serpentine", + label=_("Serpentine"), + description=_( + "Alternate the dither direction on every row to " + "avoid directional artifacts" + ), + default=False, + visible_when=is_error_diffusion, + ), + LengthVar( + key="halftone_cell_mm", + label=_("Halftone Cell Size"), + description=_("Distance between halftone dot centers"), + default=DEFAULT_HALFTONE_CELL_MM, + min_val=0.01, + max_val=50.0, + digits=3, + visible_when=is_halftone, + ), + AngleVar( + key="halftone_angle", + label=_("Halftone Angle"), + description=_("Rotation of the halftone dot grid"), + default=DEFAULT_HALFTONE_ANGLE, + min_val=0.0, + max_val=180.0, + visible_when=is_halftone, + ), ScanAngleVar(), BoolVar( key="cross_hatch", @@ -305,6 +354,9 @@ def __init__(self, name: str | None = None, typelabel: str | None = None): self.z_step_down = 0.0 self.angle_increment = 0.0 self.dither_algorithm = None + self.dither_serpentine = False + self.halftone_cell_mm = DEFAULT_HALFTONE_CELL_MM + self.halftone_angle = DEFAULT_HALFTONE_ANGLE self.bidir_x_offset_mm = 0.0 def set_dither_algorithm(self, algorithm: DitherAlgorithm | str | None): @@ -394,9 +446,16 @@ def apply_import_settings(self, settings: dict[str, Any]) -> None: "dot_width_correction_mm", "line_interval_mm", "scan_angle", + "depth_mode", + "threshold", + "dither_serpentine", + "halftone_cell_mm", + "halftone_angle", ): if key in settings: setattr(self, key, settings[key]) + if "dither_algorithm" in settings: + self.set_dither_algorithm(settings["dither_algorithm"]) def build_compute_payload( self, @@ -481,6 +540,9 @@ def get_cache_params(self) -> dict: if self.dither_algorithm is not None else None ), + "dither_serpentine": self.dither_serpentine, + "halftone_cell_mm": self.halftone_cell_mm, + "halftone_angle": self.halftone_angle, } ) return params @@ -510,6 +572,9 @@ def to_dict(self) -> dict: result["dither_algorithm"] = ( self.dither_algorithm.value if self.dither_algorithm else None ) + result["dither_serpentine"] = self.dither_serpentine + result["halftone_cell_mm"] = self.halftone_cell_mm + result["halftone_angle"] = self.halftone_angle result["bidir_x_offset_mm"] = self.bidir_x_offset_mm return result @@ -600,6 +665,13 @@ def from_dict(cls, data: dict) -> EngraveStep: step.dither_algorithm = DitherAlgorithm(dither_val) except ValueError: step.dither_algorithm = DitherAlgorithm.FLOYD_STEINBERG + step.dither_serpentine = data.get("dither_serpentine", False) + step.halftone_cell_mm = data.get( + "halftone_cell_mm", DEFAULT_HALFTONE_CELL_MM + ) + step.halftone_angle = data.get( + "halftone_angle", DEFAULT_HALFTONE_ANGLE + ) step.bidir_x_offset_mm = data.get("bidir_x_offset_mm", 0.0) return step @@ -628,6 +700,9 @@ def _serialized_keys(cls) -> frozenset[str]: "z_step_down", "angle_increment", "dither_algorithm", + "dither_serpentine", + "halftone_cell_mm", + "halftone_angle", "bidir_x_offset_mm", "min_power", "max_power", @@ -771,6 +846,10 @@ def _build_raster_part( dither_algorithm=step.dither_algorithm, laser_spot_x_mm=spot_x, pixels_per_mm_x=px_per_mm_x, + pixels_per_mm_y=px_per_mm_y, + dither_serpentine=step.dither_serpentine, + halftone_cell_mm=step.halftone_cell_mm, + halftone_angle=step.halftone_angle, ) surface.flush() if image is None: diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/raster_page.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/raster_page.py index 330381376..124671f8a 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/raster_page.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/raster_page.py @@ -19,6 +19,9 @@ "depth_mode", "threshold", "dither_algorithm", + "dither_serpentine", + "halftone_cell_mm", + "halftone_angle", "scan_angle", "cross_hatch", "scan_mode", diff --git a/rayforge/builtin_addons/rayforge-addon-laser/locale/de/LC_MESSAGES/laser_essentials.po b/rayforge/builtin_addons/rayforge-addon-laser/locale/de/LC_MESSAGES/laser_essentials.po index c0a678a26..177c113ed 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/locale/de/LC_MESSAGES/laser_essentials.po +++ b/rayforge/builtin_addons/rayforge-addon-laser/locale/de/LC_MESSAGES/laser_essentials.po @@ -802,3 +802,27 @@ msgstr "Laserkopf" #: laser_essentials/commands/material_test_cmd.py msgid "Add Material Test" msgstr "Materialtest hinzufügen" + +#: laser_essentials/steps/raster_step.py +msgid "Serpentine" +msgstr "Serpentinenmodus" + +#: laser_essentials/steps/raster_step.py +msgid "Alternate the dither direction on every row to avoid directional artifacts" +msgstr "Ditherrichtung in jeder Zeile wechseln, um richtungsabhängige Artefakte zu vermeiden" + +#: laser_essentials/steps/raster_step.py +msgid "Halftone Cell Size" +msgstr "Halbton-Zellgröße" + +#: laser_essentials/steps/raster_step.py +msgid "Distance between halftone dot centers" +msgstr "Abstand zwischen den Mittelpunkten der Halbtonpunkte" + +#: laser_essentials/steps/raster_step.py +msgid "Halftone Angle" +msgstr "Halbton-Winkel" + +#: laser_essentials/steps/raster_step.py +msgid "Rotation of the halftone dot grid" +msgstr "Drehung des Halbton-Punktrasters" diff --git a/rayforge/builtin_addons/rayforge-addon-laser/locale/en/LC_MESSAGES/laser_essentials.po b/rayforge/builtin_addons/rayforge-addon-laser/locale/en/LC_MESSAGES/laser_essentials.po index 928a924cc..aa82df27d 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/locale/en/LC_MESSAGES/laser_essentials.po +++ b/rayforge/builtin_addons/rayforge-addon-laser/locale/en/LC_MESSAGES/laser_essentials.po @@ -770,3 +770,27 @@ msgstr "" #: laser_essentials/commands/material_test_cmd.py msgid "Add Material Test" msgstr "Add Material Test" + +#: laser_essentials/steps/raster_step.py +msgid "Serpentine" +msgstr "" + +#: laser_essentials/steps/raster_step.py +msgid "Alternate the dither direction on every row to avoid directional artifacts" +msgstr "" + +#: laser_essentials/steps/raster_step.py +msgid "Halftone Cell Size" +msgstr "" + +#: laser_essentials/steps/raster_step.py +msgid "Distance between halftone dot centers" +msgstr "" + +#: laser_essentials/steps/raster_step.py +msgid "Halftone Angle" +msgstr "" + +#: laser_essentials/steps/raster_step.py +msgid "Rotation of the halftone dot grid" +msgstr "" diff --git a/rayforge/builtin_addons/rayforge-addon-laser/locale/es/LC_MESSAGES/laser_essentials.po b/rayforge/builtin_addons/rayforge-addon-laser/locale/es/LC_MESSAGES/laser_essentials.po index 0cdf551f7..ad2cc4de2 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/locale/es/LC_MESSAGES/laser_essentials.po +++ b/rayforge/builtin_addons/rayforge-addon-laser/locale/es/LC_MESSAGES/laser_essentials.po @@ -806,3 +806,27 @@ msgstr "Cabezal láser" #: laser_essentials/commands/material_test_cmd.py msgid "Add Material Test" msgstr "Añadir prueba de material" + +#: laser_essentials/steps/raster_step.py +msgid "Serpentine" +msgstr "Serpentina" + +#: laser_essentials/steps/raster_step.py +msgid "Alternate the dither direction on every row to avoid directional artifacts" +msgstr "Alternar la dirección del difuminado en cada fila para evitar artefactos direccionales" + +#: laser_essentials/steps/raster_step.py +msgid "Halftone Cell Size" +msgstr "Tamaño de celda del semitono" + +#: laser_essentials/steps/raster_step.py +msgid "Distance between halftone dot centers" +msgstr "Distancia entre los centros de los puntos del semitono" + +#: laser_essentials/steps/raster_step.py +msgid "Halftone Angle" +msgstr "Ángulo del semitono" + +#: laser_essentials/steps/raster_step.py +msgid "Rotation of the halftone dot grid" +msgstr "Rotación de la cuadrícula de puntos del semitono" diff --git a/rayforge/builtin_addons/rayforge-addon-laser/locale/fr/LC_MESSAGES/laser_essentials.po b/rayforge/builtin_addons/rayforge-addon-laser/locale/fr/LC_MESSAGES/laser_essentials.po index cdf300358..1ea71b7c2 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/locale/fr/LC_MESSAGES/laser_essentials.po +++ b/rayforge/builtin_addons/rayforge-addon-laser/locale/fr/LC_MESSAGES/laser_essentials.po @@ -803,3 +803,27 @@ msgstr "Tête laser" #: laser_essentials/commands/material_test_cmd.py msgid "Add Material Test" msgstr "Ajouter un test de matériau" + +#: laser_essentials/steps/raster_step.py +msgid "Serpentine" +msgstr "Serpentin" + +#: laser_essentials/steps/raster_step.py +msgid "Alternate the dither direction on every row to avoid directional artifacts" +msgstr "Alterner le sens du tramage à chaque ligne pour éviter les artefacts directionnels" + +#: laser_essentials/steps/raster_step.py +msgid "Halftone Cell Size" +msgstr "Taille de cellule de demi-teinte" + +#: laser_essentials/steps/raster_step.py +msgid "Distance between halftone dot centers" +msgstr "Distance entre les centres des points de demi-teinte" + +#: laser_essentials/steps/raster_step.py +msgid "Halftone Angle" +msgstr "Angle de demi-teinte" + +#: laser_essentials/steps/raster_step.py +msgid "Rotation of the halftone dot grid" +msgstr "Rotation de la grille de points de demi-teinte" diff --git a/rayforge/builtin_addons/rayforge-addon-laser/locale/hi/LC_MESSAGES/laser_essentials.po b/rayforge/builtin_addons/rayforge-addon-laser/locale/hi/LC_MESSAGES/laser_essentials.po index 9566fab10..c935dfdd1 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/locale/hi/LC_MESSAGES/laser_essentials.po +++ b/rayforge/builtin_addons/rayforge-addon-laser/locale/hi/LC_MESSAGES/laser_essentials.po @@ -784,3 +784,27 @@ msgstr "लेज़र हेड" #: laser_essentials/commands/material_test_cmd.py msgid "Add Material Test" msgstr "सामग्री परीक्षण जोड़ें" + +#: laser_essentials/steps/raster_step.py +msgid "Serpentine" +msgstr "सर्पाकार" + +#: laser_essentials/steps/raster_step.py +msgid "Alternate the dither direction on every row to avoid directional artifacts" +msgstr "दिशात्मक दोषों से बचने के लिए हर पंक्ति में डाइदर की दिशा बदलें" + +#: laser_essentials/steps/raster_step.py +msgid "Halftone Cell Size" +msgstr "हाफ़टोन सेल आकार" + +#: laser_essentials/steps/raster_step.py +msgid "Distance between halftone dot centers" +msgstr "हाफ़टोन बिंदुओं के केंद्रों के बीच की दूरी" + +#: laser_essentials/steps/raster_step.py +msgid "Halftone Angle" +msgstr "हाफ़टोन कोण" + +#: laser_essentials/steps/raster_step.py +msgid "Rotation of the halftone dot grid" +msgstr "हाफ़टोन बिंदु ग्रिड का घुमाव" diff --git a/rayforge/builtin_addons/rayforge-addon-laser/locale/laser_essentials.pot b/rayforge/builtin_addons/rayforge-addon-laser/locale/laser_essentials.pot index 78f3aff98..a312c2959 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/locale/laser_essentials.pot +++ b/rayforge/builtin_addons/rayforge-addon-laser/locale/laser_essentials.pot @@ -774,3 +774,27 @@ msgstr "" #: laser_essentials/commands/material_test_cmd.py msgid "Add Material Test" msgstr "" + +#: laser_essentials/steps/raster_step.py +msgid "Serpentine" +msgstr "" + +#: laser_essentials/steps/raster_step.py +msgid "Alternate the dither direction on every row to avoid directional artifacts" +msgstr "" + +#: laser_essentials/steps/raster_step.py +msgid "Halftone Cell Size" +msgstr "" + +#: laser_essentials/steps/raster_step.py +msgid "Distance between halftone dot centers" +msgstr "" + +#: laser_essentials/steps/raster_step.py +msgid "Halftone Angle" +msgstr "" + +#: laser_essentials/steps/raster_step.py +msgid "Rotation of the halftone dot grid" +msgstr "" diff --git a/rayforge/builtin_addons/rayforge-addon-laser/locale/pt/LC_MESSAGES/laser_essentials.po b/rayforge/builtin_addons/rayforge-addon-laser/locale/pt/LC_MESSAGES/laser_essentials.po index 19076e5d4..99d712321 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/locale/pt/LC_MESSAGES/laser_essentials.po +++ b/rayforge/builtin_addons/rayforge-addon-laser/locale/pt/LC_MESSAGES/laser_essentials.po @@ -799,3 +799,27 @@ msgstr "Cabeça de Laser" #: laser_essentials/commands/material_test_cmd.py msgid "Add Material Test" msgstr "Adicionar Teste de Material" + +#: laser_essentials/steps/raster_step.py +msgid "Serpentine" +msgstr "Serpentina" + +#: laser_essentials/steps/raster_step.py +msgid "Alternate the dither direction on every row to avoid directional artifacts" +msgstr "Alternar a direção do pontilhado em cada linha para evitar artefatos direcionais" + +#: laser_essentials/steps/raster_step.py +msgid "Halftone Cell Size" +msgstr "Tamanho da célula de meio-tom" + +#: laser_essentials/steps/raster_step.py +msgid "Distance between halftone dot centers" +msgstr "Distância entre os centros dos pontos de meio-tom" + +#: laser_essentials/steps/raster_step.py +msgid "Halftone Angle" +msgstr "Ângulo do meio-tom" + +#: laser_essentials/steps/raster_step.py +msgid "Rotation of the halftone dot grid" +msgstr "Rotação da grade de pontos de meio-tom" diff --git a/rayforge/builtin_addons/rayforge-addon-laser/locale/uk/LC_MESSAGES/laser_essentials.po b/rayforge/builtin_addons/rayforge-addon-laser/locale/uk/LC_MESSAGES/laser_essentials.po index 9360570dd..4b80f351f 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/locale/uk/LC_MESSAGES/laser_essentials.po +++ b/rayforge/builtin_addons/rayforge-addon-laser/locale/uk/LC_MESSAGES/laser_essentials.po @@ -797,3 +797,27 @@ msgstr "Лазерна голівка" #: laser_essentials/commands/material_test_cmd.py msgid "Add Material Test" msgstr "Додати тест матеріалу" + +#: laser_essentials/steps/raster_step.py +msgid "Serpentine" +msgstr "Змійкою" + +#: laser_essentials/steps/raster_step.py +msgid "Alternate the dither direction on every row to avoid directional artifacts" +msgstr "Змінювати напрямок дизерингу в кожному рядку, щоб уникнути спрямованих артефактів" + +#: laser_essentials/steps/raster_step.py +msgid "Halftone Cell Size" +msgstr "Розмір комірки півтону" + +#: laser_essentials/steps/raster_step.py +msgid "Distance between halftone dot centers" +msgstr "Відстань між центрами півтонових точок" + +#: laser_essentials/steps/raster_step.py +msgid "Halftone Angle" +msgstr "Кут півтону" + +#: laser_essentials/steps/raster_step.py +msgid "Rotation of the halftone dot grid" +msgstr "Поворот сітки півтонових точок" diff --git a/rayforge/builtin_addons/rayforge-addon-laser/locale/zh_CN/LC_MESSAGES/laser_essentials.po b/rayforge/builtin_addons/rayforge-addon-laser/locale/zh_CN/LC_MESSAGES/laser_essentials.po index d41a2a5df..02c088af7 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/locale/zh_CN/LC_MESSAGES/laser_essentials.po +++ b/rayforge/builtin_addons/rayforge-addon-laser/locale/zh_CN/LC_MESSAGES/laser_essentials.po @@ -776,3 +776,27 @@ msgstr "激光头" #: laser_essentials/commands/material_test_cmd.py msgid "Add Material Test" msgstr "添加材料测试" + +#: laser_essentials/steps/raster_step.py +msgid "Serpentine" +msgstr "蛇形扫描" + +#: laser_essentials/steps/raster_step.py +msgid "Alternate the dither direction on every row to avoid directional artifacts" +msgstr "每行交替抖动方向,以避免方向性伪影" + +#: laser_essentials/steps/raster_step.py +msgid "Halftone Cell Size" +msgstr "半色调网格尺寸" + +#: laser_essentials/steps/raster_step.py +msgid "Distance between halftone dot centers" +msgstr "半色调网点中心之间的距离" + +#: laser_essentials/steps/raster_step.py +msgid "Halftone Angle" +msgstr "半色调角度" + +#: laser_essentials/steps/raster_step.py +msgid "Rotation of the halftone dot grid" +msgstr "半色调网点网格的旋转角度" diff --git a/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_raster_step.py b/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_raster_step.py index 7875f4c7b..8c7591621 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_raster_step.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_raster_step.py @@ -11,6 +11,7 @@ from raygeo.ops.types import CommandCategory, CommandType from rayforge.core.step_registry import step_registry +from rayforge.core.varset import LabeledChoiceVar from rayforge.core.workpiece import WorkPiece from rayforge.image.dither import DitherAlgorithm from rayforge.pipeline.transformer import OpsTransformer @@ -543,3 +544,139 @@ def test_enabled_crop_keeps_arc_inside_stock(self, mock_context): assert ops.endpoint(arcs[0]) == pytest.approx( (0.6, 0.5, 0.0), abs=1e-6 ) + + +class TestEngraveDitherOptions: + """Serpentine and halftone settings of the Dither mode.""" + + def _var(self, key): + varset = EngraveStep.recipe_varset() + return next(v for v in varset.vars if v.key == key) + + def test_defaults(self): + step = EngraveStep(name="engrave") + assert step.dither_serpentine is False + assert step.halftone_cell_mm == 0.5 + assert step.halftone_angle == 45.0 + + def test_roundtrip_serialization(self): + step = EngraveStep(name="engrave") + step.depth_mode = "DITHER" + step.dither_algorithm = DitherAlgorithm.HALFTONE + step.dither_serpentine = True + step.halftone_cell_mm = 0.8 + step.halftone_angle = 15.0 + data = step.to_dict() + restored = EngraveStep.from_dict(data) + assert restored.dither_algorithm is DitherAlgorithm.HALFTONE + assert restored.dither_serpentine is True + assert restored.halftone_cell_mm == 0.8 + assert restored.halftone_angle == 15.0 + assert data == restored.to_dict() + + def test_old_documents_get_defaults(self): + data = EngraveStep(name="engrave").to_dict() + for key in ("dither_serpentine", "halftone_cell_mm", "halftone_angle"): + data.pop(key) + restored = EngraveStep.from_dict(data) + assert restored.dither_serpentine is False + assert restored.halftone_cell_mm == 0.5 + assert restored.halftone_angle == 45.0 + + def test_new_algorithms_load_by_value(self): + data = EngraveStep(name="engrave").to_dict() + data["dither_algorithm"] = "stucki" + restored = EngraveStep.from_dict(data) + assert restored.dither_algorithm is DitherAlgorithm.STUCKI + + def test_cache_params_cover_the_new_settings(self): + step = EngraveStep(name="engrave") + before = step.get_cache_params() + for attr, value in ( + ("dither_serpentine", True), + ("halftone_cell_mm", 1.2), + ("halftone_angle", 10.0), + ): + setattr(step, attr, value) + after = step.get_cache_params() + assert after[attr] == value + assert after != before + before = after + + def test_varset_lists_every_algorithm(self): + var = self._var("dither_algorithm") + assert isinstance(var, LabeledChoiceVar) + names = [var.get_value_for_display(label) for label in var.choices] + assert names == [a.name for a in DitherAlgorithm] + + @pytest.mark.parametrize( + "algo, visible", + [ + ("FLOYD_STEINBERG", True), + ("STUCKI", True), + ("ATKINSON", True), + ("BAYER4", False), + ("NEWSPRINT", False), + ("HALFTONE", False), + ], + ) + def test_serpentine_only_for_error_diffusion(self, algo, visible): + var = self._var("dither_serpentine") + values = {"depth_mode": "DITHER", "dither_algorithm": algo} + assert var.is_visible(values) is visible + values["depth_mode"] = "POWER_MODULATION" + assert var.is_visible(values) is False + + @pytest.mark.parametrize("key", ["halftone_cell_mm", "halftone_angle"]) + def test_halftone_rows_only_for_halftone(self, key): + var = self._var(key) + assert var.is_visible( + {"depth_mode": "DITHER", "dither_algorithm": "HALFTONE"} + ) + assert not var.is_visible( + {"depth_mode": "DITHER", "dither_algorithm": "STUCKI"} + ) + assert not var.is_visible( + {"depth_mode": "CONSTANT_POWER", "dither_algorithm": "HALFTONE"} + ) + + def test_apply_import_settings_sets_dither_mode(self): + step = EngraveStep(name="engrave") + step.apply_import_settings( + { + "depth_mode": "DITHER", + "dither_algorithm": "HALFTONE", + "halftone_cell_mm": 0.6, + "halftone_angle": 22.5, + } + ) + assert step.depth_mode == "DITHER" + assert step.dither_algorithm is DitherAlgorithm.HALFTONE + assert step.halftone_cell_mm == 0.6 + assert step.halftone_angle == 22.5 + + def test_build_raster_part_forwards_dither_options(self, machine): + step = EngraveStep(name="engrave") + step.depth_mode = "DITHER" + step.auto_levels = False + step.dither_algorithm = DitherAlgorithm.HALFTONE + step.dither_serpentine = True + step.halftone_cell_mm = 0.7 + step.halftone_angle = 12.0 + wp = WorkPiece(name="wp") + wp.set_size(10.0, 5.0) + surface = MagicMock() + target = "laser_essentials.steps.raster_step.preprocess_raster_image" + with ( + patch.object(WorkPiece, "render_to_pixels", return_value=surface), + patch(target, return_value=(None, None)) as preprocess, + ): + step.build_compute_payload(machine, wp) + + kwargs = preprocess.call_args.kwargs + assert kwargs["dither_algorithm"] is DitherAlgorithm.HALFTONE + assert kwargs["dither_serpentine"] is True + assert kwargs["halftone_cell_mm"] == 0.7 + assert kwargs["halftone_angle"] == 12.0 + assert kwargs["pixels_per_mm_x"] == pytest.approx(20.0) + assert kwargs["pixels_per_mm_y"] == pytest.approx(10.0) 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 3abb0501e..d07e0c16e 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 @@ -9,7 +9,7 @@ gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") -from gi.repository import Adw +from gi.repository import Adw, Gtk from laser_essentials.steps import EngraveStep, MaterialTestStep from laser_essentials.widgets.contour_page import ContourStepSettingsPage from laser_essentials.widgets.material_test_grid_page import ( @@ -462,6 +462,38 @@ def test_raster_page_mode_visibility(editor, laser_machine, ui_context): assert sample_interval.get_visible() is True +@pytest.mark.ui +def test_raster_page_dither_option_visibility( + editor, laser_machine, ui_context +): + """Serpentine follows the error-diffusion algorithms, the halftone + rows only show for Halftone.""" + step_cls = step_registry.get("EngraveStep") + assert step_cls is not None + step = cast(EngraveStep, step_cls.create(ui_context)) + step.depth_mode = "DITHER" + page = RasterSettingsPage(editor, step) + + serpentine = _row(page, "dither_serpentine") + cell = _row(page, "halftone_cell_mm") + angle = _row(page, "halftone_angle") + + step.set_dither_algorithm("STUCKI") + assert serpentine.get_visible() is True + assert cell.get_visible() is False + assert angle.get_visible() is False + + step.set_dither_algorithm("HALFTONE") + assert serpentine.get_visible() is False + assert cell.get_visible() is True + assert angle.get_visible() is True + + step.depth_mode = "POWER_MODULATION" + step.updated.send(step) + assert serpentine.get_visible() is False + assert cell.get_visible() is False + + @pytest.mark.ui def test_raster_page_shows_the_steps_dither_algorithm( editor, laser_machine, ui_context @@ -483,6 +515,29 @@ def test_raster_page_shows_the_steps_dither_algorithm( assert adapter.get_value() == "STUCKI" +@pytest.mark.ui +def test_raster_page_dither_algorithm_lists_new_methods( + editor, laser_machine, ui_context +): + step_cls = step_registry.get("EngraveStep") + assert step_cls is not None + step = cast(EngraveStep, step_cls.create(ui_context)) + step.depth_mode = "DITHER" + page = RasterSettingsPage(editor, step) + + adapter = page.engrave_widget.adapter_for("dither_algorithm") + assert isinstance(adapter, ComboAdapter) + row = cast(Adw.ComboRow, _row(page, "dither_algorithm")) + model = row.get_model() + assert model is not None + labels = [ + cast(Gtk.StringObject, model.get_item(i)).get_string() + for i in range(model.get_n_items()) + ] + for algo in DitherAlgorithm: + assert algo.display_name in labels + + def _depth_mode_adapter(page: RasterSettingsPage) -> ComboAdapter: adapter = page.engrave_widget.adapter_for("depth_mode") assert isinstance(adapter, ComboAdapter) diff --git a/rayforge/pipeline/stage/assembler_helpers.py b/rayforge/pipeline/stage/assembler_helpers.py index 743ad7118..73629a4d6 100644 --- a/rayforge/pipeline/stage/assembler_helpers.py +++ b/rayforge/pipeline/stage/assembler_helpers.py @@ -27,6 +27,8 @@ from ...core.vectorization_spec import TraceSpec from ...image.dither import ( + DEFAULT_HALFTONE_ANGLE, + DEFAULT_HALFTONE_CELL_MM, DitherAlgorithm, grayscale_to_dithered_array, ) @@ -48,7 +50,8 @@ class DepthMode(Enum): * ``POWER_MODULATION`` — variable power proportional to darkness. * ``CONSTANT_POWER`` — binary mask, constant-power scan lines. - * ``DITHER`` — Floyd-Steinberg / ordered dither to binary. + * ``DITHER`` — error-diffusion, ordered or halftone dither to + binary. * ``MULTI_PASS`` — repeated Z-stepped passes through the depth. """ @@ -298,6 +301,10 @@ def preprocess_raster_image( dither_algorithm: DitherAlgorithm | None = None, laser_spot_x_mm: float = 0.1, pixels_per_mm_x: float = 1.0, + pixels_per_mm_y: float | None = None, + dither_serpentine: bool = False, + halftone_cell_mm: float = DEFAULT_HALFTONE_CELL_MM, + halftone_angle: float = DEFAULT_HALFTONE_ANGLE, ) -> tuple[np.ndarray | None, np.ndarray | None]: """Convert a Cairo surface into an image array for a raster assembler. @@ -327,6 +334,13 @@ def preprocess_raster_image( compute minimum feature size for dithering. pixels_per_mm_x: Rendered pixels-per-mm in X, used with ``laser_spot_x_mm`` for dither minimum feature size. + pixels_per_mm_y: Rendered pixels-per-mm in Y; defaults to + ``pixels_per_mm_x``. Screens use both to keep their cells + square in millimetres. + dither_serpentine: Alternate the scan direction per row for + the error-diffusion algorithms. + halftone_cell_mm: Dot spacing of the halftone screen in mm. + halftone_angle: Halftone screen angle in degrees. Returns: ``(image, alpha)`` where *image* is a 2-D ``uint8`` array @@ -371,6 +385,13 @@ def preprocess_raster_image( gray_image, algo, min_feature_px=min_feature_px, + serpentine=dither_serpentine, + halftone_cell_mm=halftone_cell_mm, + halftone_angle=halftone_angle, + pixels_per_mm=( + pixels_per_mm_x, + pixels_per_mm_y or pixels_per_mm_x, + ), ) return image, None diff --git a/tests/pipeline/stage/test_assembler_helpers.py b/tests/pipeline/stage/test_assembler_helpers.py index 975d8a569..d40e85252 100644 --- a/tests/pipeline/stage/test_assembler_helpers.py +++ b/tests/pipeline/stage/test_assembler_helpers.py @@ -308,6 +308,79 @@ def test_dither_invert_engraves_light_areas_only(self): assert np.all(image[:, :5] == 1) assert np.all(image[:, 5:] == 0) + def _gradient_surface(self, width=120, height=60): + surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height) + ctx = cairo.Context(surface) + gradient = cairo.LinearGradient(0, 0, width, 0) + gradient.add_color_stop_rgb(0, 0, 0, 0) + gradient.add_color_stop_rgb(1, 1, 1, 1) + ctx.set_source(gradient) + ctx.paint() + return surface + + def test_dither_forwards_serpentine(self): + surface = self._gradient_surface() + kwargs = { + "mode": DepthMode.DITHER, + "auto_levels": False, + "dither_algorithm": DitherAlgorithm.STUCKI, + "laser_spot_x_mm": 0.1, + "pixels_per_mm_x": 10.0, + } + plain, _ = preprocess_raster_image(surface, **kwargs) + serpentine, _ = preprocess_raster_image( + surface, dither_serpentine=True, **kwargs + ) + + assert plain is not None and serpentine is not None + assert not np.array_equal(plain, serpentine) + + def test_dither_forwards_halftone_settings(self): + surface = self._gradient_surface() + kwargs = { + "mode": DepthMode.DITHER, + "auto_levels": False, + "dither_algorithm": DitherAlgorithm.HALFTONE, + "laser_spot_x_mm": 0.1, + "pixels_per_mm_x": 10.0, + "pixels_per_mm_y": 10.0, + } + base, _ = preprocess_raster_image( + surface, halftone_cell_mm=1.0, halftone_angle=0.0, **kwargs + ) + coarse, _ = preprocess_raster_image( + surface, halftone_cell_mm=2.0, halftone_angle=0.0, **kwargs + ) + rotated, _ = preprocess_raster_image( + surface, halftone_cell_mm=1.0, halftone_angle=30.0, **kwargs + ) + + assert base is not None and coarse is not None + assert rotated is not None + assert not np.array_equal(base, coarse) + assert not np.array_equal(base, rotated) + + def test_dither_halftone_uses_y_resolution(self): + surface = self._gradient_surface() + kwargs = { + "mode": DepthMode.DITHER, + "auto_levels": False, + "dither_algorithm": DitherAlgorithm.HALFTONE, + "laser_spot_x_mm": 0.1, + "pixels_per_mm_x": 10.0, + "halftone_cell_mm": 1.0, + "halftone_angle": 0.0, + } + square, _ = preprocess_raster_image( + surface, pixels_per_mm_y=10.0, **kwargs + ) + squashed, _ = preprocess_raster_image( + surface, pixels_per_mm_y=5.0, **kwargs + ) + + assert square is not None and squashed is not None + assert not np.array_equal(square, squashed) + def test_mask_scan_returns_binary_no_alpha(self): surface = self._black_surface() image, alpha = preprocess_raster_image( From 854f8d29d0daefede72217a7ef7bc57502b30841 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Fri, 9 Oct 2026 12:47:04 +0200 Subject: [PATCH 024/104] feat(lightburn): keep the image layer's dither mode on import CutSetting_Img layers carried their image mode in ditherMode, which the importer ignored, so every imported photo fell back to Variable Power. Map it onto the Engrave step: dithered, atkinson, stucki, jarvis, ordered, newsprint and halftone become the matching dither algorithm (halftone with its cellsPerInch and halftoneAngle), threshold becomes Constant Power and grayscale Variable Power. Sketch has no counterpart and keeps the default. Refs #514 --- rayforge/image/lightburn/importer.py | 39 ++++++++++- .../lightburn/test_lightburn_importer.py | 69 +++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) diff --git a/rayforge/image/lightburn/importer.py b/rayforge/image/lightburn/importer.py index d464053a0..cf3b2c66a 100644 --- a/rayforge/image/lightburn/importer.py +++ b/rayforge/image/lightburn/importer.py @@ -361,11 +361,48 @@ def _shape_to_geometry( return (cut_index, geo) +#: LightBurn image modes (``ditherMode``) that map onto a dither +#: algorithm of the Engrave step. +_LIGHTBURN_DITHER_ALGORITHMS = { + "dithered": "FLOYD_STEINBERG", + "atkinson": "ATKINSON", + "stucki": "STUCKI", + "jarvis": "JARVIS_JUDICE_NINKE", + "ordered": "BAYER8", + "newsprint": "NEWSPRINT", + "halftone": "HALFTONE", +} + + +def _build_image_mode_config(cs: dict[str, Any]) -> dict[str, Any]: + """Translate LightBurn's image mode to Engrave-step settings.""" + mode = cs.get("ditherMode") + if mode == "threshold": + return {"depth_mode": "CONSTANT_POWER", "threshold": 128} + if mode == "grayscale": + return {"depth_mode": "POWER_MODULATION"} + algorithm = _LIGHTBURN_DITHER_ALGORITHMS.get(str(mode)) + if algorithm is None: + return {} + config: dict[str, Any] = { + "depth_mode": "DITHER", + "dither_algorithm": algorithm, + } + if algorithm == "HALFTONE": + cells_per_inch = cs.get("cellsPerInch") + if cells_per_inch: + config["halftone_cell_mm"] = 25.4 / float(cells_per_inch) + angle = cs.get("halftoneAngle") + if angle is not None: + config["halftone_angle"] = float(angle) + return config + + def _build_step_config( cs: dict[str, Any], ) -> dict[str, Any] | None: """Translate LightBurn cut settings to generic step configuration.""" - config: dict[str, Any] = {} + config: dict[str, Any] = _build_image_mode_config(cs) max_power = cs.get("maxPower") if max_power is not None: config["power"] = float(max_power) / 100.0 diff --git a/tests/image/lightburn/test_lightburn_importer.py b/tests/image/lightburn/test_lightburn_importer.py index 92086bfbb..6b76ceea3 100644 --- a/tests/image/lightburn/test_lightburn_importer.py +++ b/tests/image/lightburn/test_lightburn_importer.py @@ -11,6 +11,7 @@ from rayforge.core.step_registry import step_registry from rayforge.core.vectorization_spec import PassthroughSpec from rayforge.image.assembler import ItemAssembler +from rayforge.image.dither import DitherAlgorithm from rayforge.image.lightburn.importer import ( LightBurnImporter, _apply_xform_to_geo, @@ -233,6 +234,55 @@ def test_num_passes_mapping(self): assert config is not None assert config == {"passes": 3} + @pytest.mark.parametrize( + "mode, algorithm", + [ + ("dithered", "FLOYD_STEINBERG"), + ("atkinson", "ATKINSON"), + ("stucki", "STUCKI"), + ("jarvis", "JARVIS_JUDICE_NINKE"), + ("ordered", "BAYER8"), + ("newsprint", "NEWSPRINT"), + ], + ) + def test_dither_mode_mapping(self, mode, algorithm): + config = _build_step_config({"ditherMode": mode}) + assert config == { + "depth_mode": "DITHER", + "dither_algorithm": algorithm, + } + + def test_halftone_mapping_converts_cells_per_inch(self): + cs = { + "ditherMode": "halftone", + "cellsPerInch": 50, + "halftoneAngle": 22.5, + } + config = _build_step_config(cs) + assert config is not None + assert config["depth_mode"] == "DITHER" + assert config["dither_algorithm"] == "HALFTONE" + assert config["halftone_cell_mm"] == pytest.approx(25.4 / 50) + assert config["halftone_angle"] == 22.5 + + def test_halftone_settings_ignored_for_other_modes(self): + cs = {"ditherMode": "stucki", "cellsPerInch": 50, "halftoneAngle": 9} + config = _build_step_config(cs) + assert config is not None + assert "halftone_cell_mm" not in config + assert "halftone_angle" not in config + + def test_threshold_mode_maps_to_constant_power(self): + config = _build_step_config({"ditherMode": "threshold"}) + assert config == {"depth_mode": "CONSTANT_POWER", "threshold": 128} + + def test_grayscale_mode_maps_to_variable_power(self): + config = _build_step_config({"ditherMode": "grayscale"}) + assert config == {"depth_mode": "POWER_MODULATION"} + + def test_unknown_dither_mode_is_ignored(self): + assert _build_step_config({"ditherMode": "sketch"}) is None + def test_all_keys(self): cs = {"maxPower": 70, "speed": 6.66667, "kerf": 0.06, "numPasses": 2} config = _build_step_config(cs) @@ -627,6 +677,25 @@ def test_image_layer_import_produces_engrave_step(self): assert step.min_power_level == 0.1 assert step.max_power_level == 0.5 + def test_image_layer_import_keeps_dither_mode(self): + """images.lbrn2 engraves its bitmaps with LightBurn's + ``dithered`` (Floyd-Steinberg) mode; the step must follow.""" + if step_registry.get("EngraveStep") is None: + pytest.skip("EngraveStep not registered") + importer = self._import("images.lbrn2") + result = importer.get_doc_items() + assert result is not None and result.payload is not None + steps = [ + step + for item in result.payload.items + if isinstance(item, Layer) and item.workflow is not None + for step in item.workflow.steps + ] + assert steps + step = cast(Any, steps[0]) + assert step.depth_mode == "DITHER" + assert step.dither_algorithm is DitherAlgorithm.FLOYD_STEINBERG + class TestApplySettingsDispatch: """ItemAssembler._apply_settings must pick its step type from From 1c1992fd8b01bf839f43699f1843cc6d2e888270 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Fri, 9 Oct 2026 12:47:04 +0200 Subject: [PATCH 025/104] docs: document the new dithering algorithms Refs #514 --- CHANGELOG.md | 6 ++++ README.md | 2 +- website/docs/features/operations/engrave.md | 28 +++++++++++++---- .../current/features/operations/engrave.md | 29 ++++++++++++++---- .../current/features/operations/engrave.md | 30 +++++++++++++++---- .../current/features/operations/engrave.md | 30 +++++++++++++++---- .../current/features/operations/engrave.md | 28 +++++++++++++---- .../current/features/operations/engrave.md | 29 ++++++++++++++---- .../current/features/operations/engrave.md | 28 +++++++++++++---- .../current/features/operations/engrave.md | 23 ++++++++++---- 10 files changed, 184 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b72239885..fbb2354f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 the page size (#517) - Import: GIF, TIFF and WebP bitmaps, traced like PNG. Animated GIFs and multi-page TIFFs import their first frame or page (#517) +- Engrave: more dithering algorithms for photos. The error-diffusion + kernels Atkinson, Stucki, Jarvis-Judice-Ninke, Sierra (3-row, + 2-row, Lite) and Burkes, with an optional serpentine scan, plus a + clustered-dot Newsprint screen and a round-dot Halftone screen with + adjustable cell size and angle. LightBurn imports keep the image + layer's dither mode (#514) - Sketcher: boolean operations. Union, Difference, Intersection and Exclude bake the selected closed regions into multi-ring polygons (outer contours counter-clockwise, hole contours clockwise), so diff --git a/README.md b/README.md index ed9942b33..85df22067 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ We also have a [Discord](https://discord.gg/sTHNdTtpQJ). | **Animated 3D Simulation** | Simulate toolpaths in 3D with animated playback, scrubber, and speed control. | | **Holding Tabs** | Add tabs to contour cuts. Supports manual and automatic placement. | | **Overscan & Kerf Comp.** | Improve engraving quality with overscan; ensure dimensional accuracy with kerf compensation. | -| **Dithering Algorithms** | Floyd-Steinberg and Bayer ordered dithering for high-quality raster engraving. | +| **Dithering Algorithms** | Floyd-Steinberg, Stucki, Jarvis, Atkinson, Sierra, Burkes, Bayer, newsprint and halftone screens. | | **Post-Processors** | Lead-in/lead-out, merge overlapping lines, and crop toolpaths to stock boundary. | | **Advanced Path Generation** | Image tracing, travel time optimization, path smoothing, and spot size interpolation. | diff --git a/website/docs/features/operations/engrave.md b/website/docs/features/operations/engrave.md index 536099346..7849a1240 100644 --- a/website/docs/features/operations/engrave.md +++ b/website/docs/features/operations/engrave.md @@ -229,12 +229,19 @@ This is particularly useful for: Choose the algorithm that best suits your image and material: -| Algorithm | Quality | Speed | Best For | -| --------------- | ------- | ------- | ----------------------------------- | -| Floyd-Steinberg | Highest | Slowest | Photos, portraits, smooth gradients | -| Bayer 2x2 | Low | Fastest | Coarse halftone effect | -| Bayer 4x4 | Medium | Fast | Balanced halftone | -| Bayer 8x8 | High | Medium | Fine detail, subtle patterns | +| Algorithm | Quality | Speed | Best For | +| --------------------------- | ------- | ------- | ------------------------------------- | +| Floyd-Steinberg | Highest | Slowest | Photos, portraits, smooth gradients | +| Bayer 2x2 | Low | Fastest | Coarse halftone effect | +| Bayer 4x4 | Medium | Fast | Balanced halftone | +| Bayer 8x8 | High | Medium | Fine detail, subtle patterns | +| Atkinson | Medium | Medium | High-contrast images, wood | +| Stucki | Highest | Slow | Portraits, smooth skin tones | +| Jarvis, Judice & Ninke | Highest | Slow | Soft gradients, fine grain | +| Sierra (3-row, 2-row, Lite) | High | Medium | Lighter alternatives to Stucki | +| Burkes | High | Medium | Photos, faster than Stucki | +| Newsprint | Medium | Fastest | Clustered newspaper-style dots | +| Halftone | Medium | Fast | Round dots, adjustable size and angle | **Floyd-Steinberg** is default and recommended for most photo engravings. It uses error diffusion to distribute quantization errors to neighboring pixels, creating natural-looking results. @@ -242,6 +249,15 @@ distribute quantization errors to neighboring pixels, creating natural-looking r **Bayer dithering** creates regular patterns that can produce artistic effects resembling traditional halftone printing. +**Stucki, Jarvis, Sierra, Burkes and Atkinson** are error-diffusion algorithms like Floyd-Steinberg +that spread the error over a wider neighborhood, which gives a smoother grain in flat areas such as +skin tones. Atkinson diffuses only part of the error, which raises contrast. Turn on **Serpentine** +to alternate the scan direction on every row and avoid directional streaks. + +**Newsprint** and **Halftone** group pixels into dots. Halftone draws round dots on a grid set by +**Halftone Cell Size** (the distance between dot centers) and **Halftone Angle**; use a cell size of +at least a few laser spot widths. + #### Multiple Depths Mode Settings ![Multiple Depths mode settings](/screenshots/step-settings-engrave-general-multi_pass.webp) diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/engrave.md b/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/engrave.md index 27e5f46ca..c245fac9c 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/engrave.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/engrave.md @@ -227,12 +227,19 @@ Dies ist besonders nützlich für: Wähle den Algorithmus, der am besten zu deinem Bild und Material passt: -| Algorithmus | Qualität | Geschwindigkeit | Am besten für | -| --------------- | -------- | --------------- | -------------------------------- | -| Floyd-Steinberg | Höchste | Langsamste | Fotos, Porträts, sanfte Verläufe | -| Bayer 2x2 | Niedrig | Schnellste | Grober Halbton-Effekt | -| Bayer 4x4 | Mittel | Schnell | Ausgewogener Halbton | -| Bayer 8x8 | Hoch | Mittel | Feine Details, subtile Muster | +| Algorithmus | Qualität | Geschwindigkeit | Am besten für | +| --------------------------------- | -------- | --------------- | ------------------------------------------ | +| Floyd-Steinberg | Höchste | Langsamste | Fotos, Porträts, sanfte Verläufe | +| Bayer 2x2 | Niedrig | Schnellste | Grober Halbton-Effekt | +| Bayer 4x4 | Mittel | Schnell | Ausgewogener Halbton | +| Bayer 8x8 | Hoch | Mittel | Feine Details, subtile Muster | +| Atkinson | Mittel | Mittel | Kontrastreiche Bilder, Holz | +| Stucki | Höchste | Langsam | Porträts, weiche Hauttöne | +| Jarvis, Judice & Ninke | Höchste | Langsam | Sanfte Verläufe, feine Körnung | +| Sierra (3-zeilig, 2-zeilig, Lite) | Hoch | Mittel | Leichtere Alternativen zu Stucki | +| Burkes | Hoch | Mittel | Fotos, schneller als Stucki | +| Zeitungsdruck | Mittel | Schnellste | Gruppierte Punkte wie im Zeitungsdruck | +| Halbton | Mittel | Schnell | Runde Punkte, Größe und Winkel einstellbar | **Floyd-Steinberg** ist Standard und empfohlen für die meisten Fotogravuren. Es verwendet Fehler-Diffusion, um Quantisierungsfehler auf benachbarte Pixel zu verteilen, was natürlich @@ -241,6 +248,16 @@ aussehende Ergebnisse erzeugt. **Bayer-Dithering** erzeugt regelmäßige Muster, die künstlerische Effekte erzeugen können, die traditionellem Halbton-Druck ähneln. +**Stucki, Jarvis, Sierra, Burkes und Atkinson** sind wie Floyd-Steinberg Fehler-Diffusionsverfahren, +verteilen den Fehler aber über eine größere Umgebung. Das ergibt eine ruhigere Körnung in +gleichmäßigen Flächen wie Hauttönen. Atkinson verteilt nur einen Teil des Fehlers und erhöht so den +Kontrast. Schalte **Serpentinenmodus** ein, um die Abtastrichtung in jeder Zeile zu wechseln und +richtungsabhängige Streifen zu vermeiden. + +**Zeitungsdruck** und **Halbton** fassen Pixel zu Punkten zusammen. Halbton setzt runde Punkte auf +ein Raster, das du mit **Halbton-Zellgröße** (Abstand zwischen den Punktmittelpunkten) und +**Halbton-Winkel** einstellst. Wähle eine Zellgröße von mindestens einigen Laserpunktbreiten. + #### Mehrfach-Tiefen-Modus-Einstellungen ![Einstellungen für Mehrfach-Tiefen-Modus](/screenshots/step-settings-engrave-general-multi_pass.webp) diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/engrave.md b/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/engrave.md index 58d12d5a4..d55355e60 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/engrave.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/engrave.md @@ -226,12 +226,19 @@ Esto es particularmente útil para: Elige el algoritmo que mejor se adapte a tu imagen y material: -| Algoritmo | Calidad | Velocidad | Mejor Para | -| --------------- | ------- | ---------- | ---------------------------------- | -| Floyd-Steinberg | Máxima | Más lento | Fotos, retratos, degradados suaves | -| Bayer 2x2 | Baja | Más rápido | Efecto de media tinta grueso | -| Bayer 4x4 | Media | Rápido | Media tinta equilibrada | -| Bayer 8x8 | Alta | Media | Detalle fino, patrones sutiles | +| Algoritmo | Calidad | Velocidad | Mejor Para | +| ------------------------------- | ------- | ---------- | ------------------------------------------- | +| Floyd-Steinberg | Máxima | Más lento | Fotos, retratos, degradados suaves | +| Bayer 2x2 | Baja | Más rápido | Efecto de media tinta grueso | +| Bayer 4x4 | Media | Rápido | Media tinta equilibrada | +| Bayer 8x8 | Alta | Media | Detalle fino, patrones sutiles | +| Atkinson | Media | Media | Imágenes de alto contraste, madera | +| Stucki | Máxima | Lento | Retratos, tonos de piel suaves | +| Jarvis, Judice y Ninke | Máxima | Lento | Degradados suaves, grano fino | +| Sierra (3 filas, 2 filas, Lite) | Alta | Media | Alternativas más ligeras a Stucki | +| Burkes | Alta | Media | Fotos, más rápido que Stucki | +| Papel prensa | Media | Más rápido | Puntos agrupados al estilo periódico | +| Semitono | Media | Rápido | Puntos redondos, tamaño y ángulo ajustables | **Floyd-Steinberg** es el predeterminado y el recomendado para la mayoría de los grabados de fotos. Usa difusión de error para distribuir los errores de cuantización a los píxeles vecinos, creando @@ -240,6 +247,17 @@ resultados de aspecto natural. **El tramado Bayer** crea patrones regulares que pueden producir efectos artísticos que asemejan la impresión tradicional de media tinta. +**Stucki, Jarvis, Sierra, Burkes y Atkinson** son algoritmos de difusión de error como +Floyd-Steinberg que reparten el error en un entorno más amplio, lo que da un grano más uniforme en +zonas planas como los tonos de piel. Atkinson solo difunde parte del error, lo que aumenta el +contraste. Activa **Serpentina** para alternar la dirección de barrido en cada fila y evitar rayas +direccionales. + +**Papel prensa** y **Semitono** agrupan los píxeles en puntos. Semitono dibuja puntos redondos sobre +una cuadrícula definida por **Tamaño de celda del semitono** (la distancia entre los centros de los +puntos) y **Ángulo del semitono**; usa un tamaño de celda de al menos varias veces el ancho del +punto láser. + #### Ajustes del Modo Múltiples Profundidades ![Ajustes del modo Múltiples Profundidades](/screenshots/step-settings-engrave-general-multi_pass.webp) diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/engrave.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/engrave.md index ca536dd66..ffd7947e1 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/engrave.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/engrave.md @@ -227,12 +227,19 @@ C'est particulièrement utile pour : Choisissez l'algorithme qui convient le mieux à votre image et à votre matériau : -| Algorithme | Qualité | Vitesse | Idéal Pour | -| --------------- | -------------- | -------------- | ---------------------------------- | -| Floyd-Steinberg | La plus élevée | La plus lente | Photos, portraits, dégradés lisses | -| Bayer 2x2 | Basse | La plus rapide | Effet demi-teinte grossier | -| Bayer 4x4 | Moyenne | Rapide | Demi-teinte équilibrée | -| Bayer 8x8 | Élevée | Moyenne | Détails fins, motifs subtils | +| Algorithme | Qualité | Vitesse | Idéal Pour | +| --------------------------------- | -------------- | -------------- | --------------------------------------- | +| Floyd-Steinberg | La plus élevée | La plus lente | Photos, portraits, dégradés lisses | +| Bayer 2x2 | Basse | La plus rapide | Effet demi-teinte grossier | +| Bayer 4x4 | Moyenne | Rapide | Demi-teinte équilibrée | +| Bayer 8x8 | Élevée | Moyenne | Détails fins, motifs subtils | +| Atkinson | Moyenne | Moyenne | Images contrastées, bois | +| Stucki | La plus élevée | Lente | Portraits, tons chair doux | +| Jarvis, Judice et Ninke | La plus élevée | Lente | Dégradés doux, grain fin | +| Sierra (3 lignes, 2 lignes, Lite) | Élevée | Moyenne | Alternatives plus légères à Stucki | +| Burkes | Élevée | Moyenne | Photos, plus rapide que Stucki | +| Papier journal | Moyenne | La plus rapide | Points groupés façon journal | +| Demi-teinte | Moyenne | Rapide | Points ronds, taille et angle réglables | **Floyd-Steinberg** est le défaut et recommandé pour la plupart des gravures photo. Il utilise la diffusion d'erreur pour distribuer les erreurs de quantification aux pixels voisins, créant des @@ -241,6 +248,17 @@ résultats d'apparence naturelle. **Le tramage Bayer** crée des motifs réguliers qui peuvent produire des effets artistiques ressemblant à l'impression demi-teinte traditionnelle. +**Stucki, Jarvis, Sierra, Burkes et Atkinson** sont des algorithmes de diffusion d'erreur comme +Floyd-Steinberg qui répartissent l'erreur sur un voisinage plus large, ce qui donne un grain plus +régulier dans les zones uniformes comme les tons chair. Atkinson ne diffuse qu'une partie de +l'erreur, ce qui augmente le contraste. Active **Serpentin** pour alterner le sens de balayage à +chaque ligne et éviter les stries directionnelles. + +**Papier journal** et **Demi-teinte** regroupent les pixels en points. Demi-teinte trace des points +ronds sur une grille définie par **Taille de cellule de demi-teinte** (la distance entre les centres +des points) et **Angle de demi-teinte** ; choisis une taille de cellule d'au moins quelques largeurs +de point laser. + #### Paramètres du Mode Profondeurs Multiples ![Paramètres du mode Profondeurs Multiples](/screenshots/step-settings-engrave-general-multi_pass.webp) diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/engrave.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/engrave.md index 399dba324..675d3014d 100644 --- a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/engrave.md +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/engrave.md @@ -230,12 +230,19 @@ _Step Settings_ टैब के **उत्कीर्णन** और **शक वह एल्गोरिदम चुनें जो आपकी छवि और सामग्री के लिए सर्वोत्तम हो: -| एल्गोरिदम | गुणवत्ता | गति | सर्वोत्तम के लिए | -| --------------- | -------- | --------- | --------------------------------- | -| Floyd-Steinberg | सर्वाधिक | सबसे धीमा | फ़ोटो, पोर्ट्रेट, चिकने ग्रेडिएंट | -| Bayer 2x2 | कम | सबसे तेज़ | मोटा हाफ़टोन प्रभाव | -| Bayer 4x4 | मध्यम | तेज़ | संतुलित हाफ़टोन | -| Bayer 8x8 | अधिक | मध्यम | महीन विवरण, सूक्ष्म पैटर्न | +| एल्गोरिदम | गुणवत्ता | गति | सर्वोत्तम के लिए | +| --------------------------------- | -------- | --------- | --------------------------------- | +| Floyd-Steinberg | सर्वाधिक | सबसे धीमा | फ़ोटो, पोर्ट्रेट, चिकने ग्रेडिएंट | +| Bayer 2x2 | कम | सबसे तेज़ | मोटा हाफ़टोन प्रभाव | +| Bayer 4x4 | मध्यम | तेज़ | संतुलित हाफ़टोन | +| Bayer 8x8 | अधिक | मध्यम | महीन विवरण, सूक्ष्म पैटर्न | +| Atkinson | मध्यम | मध्यम | उच्च कंट्रास्ट वाली छवियाँ, लकड़ी | +| Stucki | सर्वाधिक | धीमा | पोर्ट्रेट, चिकने त्वचा टोन | +| Jarvis, Judice और Ninke | सर्वाधिक | धीमा | मुलायम ग्रेडिएंट, महीन दाना | +| Sierra (3-पंक्ति, 2-पंक्ति, Lite) | अधिक | मध्यम | Stucki के हल्के विकल्प | +| Burkes | अधिक | मध्यम | फ़ोटो, Stucki से तेज़ | +| अख़बारी छपाई | मध्यम | सबसे तेज़ | अख़बार जैसे समूहित बिंदु | +| हाफ़टोन | मध्यम | तेज़ | गोल बिंदु, आकार और कोण समायोज्य | **Floyd-Steinberg** डिफ़ॉल्ट है और अधिकांश फ़ोटो उत्कीर्णनों के लिए अनुशंसित. यह मात्रीकरण त्रुटियों को पड़ोसी पिक्सेलों में वितरित करने के लिए त्रुटि विसरण उपयोग करता है, जिससे प्राकृतिक दिखने वाले @@ -243,6 +250,15 @@ _Step Settings_ टैब के **उत्कीर्णन** और **शक **Bayer डाइदरिंग** नियमित पैटर्न बनाती है जो पारंपरिक हाफ़टोन छपाई जैसे कलात्मक प्रभाव दे सकते हैं. +**Stucki, Jarvis, Sierra, Burkes और Atkinson** Floyd-Steinberg की तरह त्रुटि-प्रसार एल्गोरिदम हैं, +जो त्रुटि को अधिक बड़े पड़ोस में फैलाते हैं. इससे त्वचा टोन जैसे समतल क्षेत्रों में दाना अधिक चिकना +होता है. Atkinson त्रुटि का केवल एक भाग फैलाता है, जिससे कंट्रास्ट बढ़ता है. हर पंक्ति में स्कैन +दिशा बदलने और दिशात्मक धारियों से बचने के लिए **सर्पाकार** चालू करें. + +**अख़बारी छपाई** और **हाफ़टोन** पिक्सेल को बिंदुओं में समूहित करते हैं. हाफ़टोन एक ग्रिड पर गोल +बिंदु बनाता है, जिसे **हाफ़टोन सेल आकार** (बिंदु केंद्रों के बीच की दूरी) और **हाफ़टोन कोण** से तय +किया जाता है; सेल आकार कम से कम कुछ लेज़र बिंदु चौड़ाई जितना रखें. + #### अनेक गहराइयाँ मोड सेटिंग्स ![अनेक गहराइयाँ मोड सेटिंग्स](/screenshots/step-settings-engrave-general-multi_pass.webp) diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/engrave.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/engrave.md index 141e4901f..05d9c7035 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/engrave.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/engrave.md @@ -226,12 +226,19 @@ Isso é particularmente útil para: Escolha o algoritmo que melhor se adapta à sua imagem e material: -| Algoritmo | Qualidade | Velocidade | Melhor Para | -| --------------- | --------- | ----------- | ---------------------------------- | -| Floyd-Steinberg | Mais Alta | Mais Lento | Fotos, retratos, gradientes suaves | -| Bayer 2x2 | Baixa | Mais Rápido | Efeito meio-tom grosseiro | -| Bayer 4x4 | Média | Rápido | Meio-tom balanceado | -| Bayer 8x8 | Alta | Médio | Detalhe fino, padrões sutis | +| Algoritmo | Qualidade | Velocidade | Melhor Para | +| --------------------------------- | --------- | ----------- | -------------------------------------------- | +| Floyd-Steinberg | Mais Alta | Mais Lento | Fotos, retratos, gradientes suaves | +| Bayer 2x2 | Baixa | Mais Rápido | Efeito meio-tom grosseiro | +| Bayer 4x4 | Média | Rápido | Meio-tom balanceado | +| Bayer 8x8 | Alta | Médio | Detalhe fino, padrões sutis | +| Atkinson | Média | Médio | Imagens de alto contraste, madeira | +| Stucki | Mais Alta | Lento | Retratos, tons de pele suaves | +| Jarvis, Judice e Ninke | Mais Alta | Lento | Gradientes suaves, grão fino | +| Sierra (3 linhas, 2 linhas, Lite) | Alta | Médio | Alternativas mais leves ao Stucki | +| Burkes | Alta | Médio | Fotos, mais rápido que Stucki | +| Papel de jornal | Média | Mais Rápido | Pontos agrupados no estilo jornal | +| Meio-tom | Média | Rápido | Pontos redondos, tamanho e ângulo ajustáveis | **Floyd-Steinberg** é padrão e recomendado para a maioria das gravações de fotos. Usa difusão de erro para distribuir erros de quantização para pixels vizinhos, criando resultados de aparência @@ -240,6 +247,16 @@ natural. **Pontilhamento Bayer** cria padrões regulares que podem produzir efeitos artísticos lembrando impressão tradicional de meio-tom. +**Stucki, Jarvis, Sierra, Burkes e Atkinson** são algoritmos de difusão de erro como o +Floyd-Steinberg que espalham o erro por uma vizinhança maior, o que dá um grão mais uniforme em +áreas planas como tons de pele. O Atkinson difunde só parte do erro, o que aumenta o contraste. +Ative **Serpentina** para alternar a direção de varredura em cada linha e evitar listras +direcionais. + +**Papel de jornal** e **Meio-tom** agrupam os pixels em pontos. O meio-tom desenha pontos redondos +numa grade definida por **Tamanho da célula de meio-tom** (a distância entre os centros dos pontos) +e **Ângulo do meio-tom**; use um tamanho de célula de pelo menos algumas larguras do ponto do laser. + #### Configurações do Modo Múltiplas Profundidades ![Configurações do modo Múltiplas Profundidades](/screenshots/step-settings-engrave-general-multi_pass.webp) diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/engrave.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/engrave.md index c40e63e49..4e960182c 100644 --- a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/engrave.md +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/engrave.md @@ -226,12 +226,19 @@ Виберіть алгоритм, який найкраще підходить для вашого зображення та матеріалу: -| Алгоритм | Якість | Швидкість | Найкраще для | -| --------------- | ------- | ------------- | -------------------------------- | -| Floyd-Steinberg | Найвища | Найповільніша | Фото, портрети, плавні градієнти | -| Bayer 2x2 | Низька | Найшвидша | Грубий ефект півтону | -| Bayer 4x4 | Середня | Швидка | Збалансований півтони | -| Bayer 8x8 | Висока | Середня | Тонкі деталі, тонкі патерни | +| Алгоритм | Якість | Швидкість | Найкраще для | +| ------------------------------------- | ------- | ------------- | -------------------------------------- | +| Floyd-Steinberg | Найвища | Найповільніша | Фото, портрети, плавні градієнти | +| Bayer 2x2 | Низька | Найшвидша | Грубий ефект півтону | +| Bayer 4x4 | Середня | Швидка | Збалансований півтони | +| Bayer 8x8 | Висока | Середня | Тонкі деталі, тонкі патерни | +| Atkinson | Середня | Середня | Контрастні зображення, дерево | +| Stucki | Найвища | Повільна | Портрети, м'які тони шкіри | +| Jarvis, Judice і Ninke | Найвища | Повільна | М'які градієнти, дрібне зерно | +| Sierra (3-рядковий, 2-рядковий, Lite) | Висока | Середня | Легші альтернативи Stucki | +| Burkes | Висока | Середня | Фото, швидше за Stucki | +| Газетний друк | Середня | Найшвидша | Згруповані точки як у газеті | +| Півтон | Середня | Швидка | Круглі точки, регульовані розмір і кут | **Floyd-Steinberg** — за замовчуванням та рекомендується для більшості фотогравіювань. Він використовує дифузію помилок для розподілу помилок квантування до сусідніх пікселів, створюючи @@ -240,6 +247,15 @@ **Дизеринг Байєра** створює регулярні патерни, які можуть давати художні ефекти, що нагадують традиційний друк півтонами. +**Stucki, Jarvis, Sierra, Burkes і Atkinson** — алгоритми дифузії помилки, як Floyd-Steinberg, що +розподіляють помилку на ширше оточення. Це дає рівніше зерно на однорідних ділянках, наприклад на +тонах шкіри. Atkinson розподіляє лише частину помилки, що підвищує контраст. Увімкніть **Змійкою**, +щоб змінювати напрямок сканування в кожному рядку й уникати спрямованих смуг. + +**Газетний друк** і **Півтон** групують пікселі в точки. Півтон малює круглі точки на сітці, яку +задають **Розмір комірки півтону** (відстань між центрами точок) і **Кут півтону**; обирайте розмір +комірки щонайменше в кілька ширин лазерної плями. + #### Налаштування режиму кількох глибин ![Налаштування режиму кількох глибин](/screenshots/step-settings-engrave-general-multi_pass.webp) diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/engrave.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/engrave.md index 7011da6c1..5bd1da9cc 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/engrave.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/engrave.md @@ -211,18 +211,29 @@ 选择最适合您的图像和材料的算法: -| 算法 | 质量 | 速度 | 最适用于 | -| --------------- | ---- | ---- | -------------------- | -| Floyd-Steinberg | 最高 | 最慢 | 照片、人像、平滑渐变 | -| Bayer 2x2 | 低 | 最快 | 粗糙的半色调效果 | -| Bayer 4x4 | 中等 | 快 | 平衡的半色调 | -| Bayer 8x8 | 高 | 中等 | 精细细节、微妙的图案 | +| 算法 | 质量 | 速度 | 最适用于 | +| -------------------------- | ---- | ---- | ------------------------ | +| Floyd-Steinberg | 最高 | 最慢 | 照片、人像、平滑渐变 | +| Bayer 2x2 | 低 | 最快 | 粗糙的半色调效果 | +| Bayer 4x4 | 中等 | 快 | 平衡的半色调 | +| Bayer 8x8 | 高 | 中等 | 精细细节、微妙的图案 | +| Atkinson | 中等 | 中等 | 高对比度图像、木材 | +| Stucki | 最高 | 慢 | 人像、平滑肤色 | +| Jarvis, Judice & Ninke | 最高 | 慢 | 柔和渐变、细腻颗粒 | +| Sierra(三行、两行、Lite) | 高 | 中等 | 比 Stucki 更轻量的替代 | +| Burkes | 高 | 中等 | 照片,比 Stucki 快 | +| 报纸印刷 | 中等 | 最快 | 报纸风格的聚集网点 | +| 半色调 | 中等 | 快 | 圆形网点,尺寸和角度可调 | **Floyd-Steinberg** 是默认选择,推荐用于大多数照片雕刻。它使用误差扩散将量化误差分配到相邻像素,从而产生自然的效果。 **Bayer 抖动**会创建规则的图案,可产生类似传统半色调印刷的艺术效果。 +**Stucki、Jarvis、Sierra、Burkes 和 Atkinson** 与 Floyd-Steinberg 一样属于误差扩散算法,但会把误差分散到更大的邻域,在肤色等平坦区域中颗粒更平滑。Atkinson 只扩散部分误差,因此对比度更高。启用**蛇形扫描**可在每一行交替扫描方向,避免方向性条纹。 + +**报纸印刷**和**半色调**会把像素聚集成网点。半色调在网格上绘制圆形网点,网格由**半色调网格尺寸**(网点中心之间的距离)和**半色调角度**决定;网格尺寸应至少为激光光斑宽度的几倍。 + #### 多深度模式设置 ![多深度模式设置](/screenshots/step-settings-engrave-general-multi_pass.webp) From 9849c7b01359d37a668465dc63a2ccd99c43452c Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Fri, 9 Oct 2026 12:57:42 +0200 Subject: [PATCH 026/104] feat(editor): add path clean-up commands for workpieces Close open contours whose ends meet within a tolerance, join open contours into longer paths (reversing pieces where needed), remove duplicate contours and stacked duplicate workpieces, and break a workpiece apart into one workpiece per contour. The geometry work lives in rayforge.core.path_cleanup and runs in millimetres, so tolerances do not depend on the workpiece scale. Gaps are bridged with straight segments between existing end points, so the bounding box and placement stay the same. Each command writes the result through ChangePropertyCommand on _edited_boundaries and is a single undo step. Break Apart reuses SplitCmd with a new ContourSplitStrategy that, unlike the connectivity split, keeps holes and open paths as pieces of their own. Refs #516 --- rayforge/core/path_cleanup.py | 321 +++++++++++++++++++++++ rayforge/doceditor/editor.py | 2 + rayforge/doceditor/path_cleanup_cmd.py | 232 ++++++++++++++++ rayforge/doceditor/split_cmd.py | 18 +- tests/core/test_path_cleanup.py | 314 ++++++++++++++++++++++ tests/doceditor/test_path_cleanup_cmd.py | 195 ++++++++++++++ 6 files changed, 1081 insertions(+), 1 deletion(-) create mode 100644 rayforge/core/path_cleanup.py create mode 100644 rayforge/doceditor/path_cleanup_cmd.py create mode 100644 tests/core/test_path_cleanup.py create mode 100644 tests/doceditor/test_path_cleanup_cmd.py diff --git a/rayforge/core/path_cleanup.py b/rayforge/core/path_cleanup.py new file mode 100644 index 000000000..8a1c3cbcf --- /dev/null +++ b/rayforge/core/path_cleanup.py @@ -0,0 +1,321 @@ +""" +Clean-up operations for vector paths. + +These functions close, join, de-duplicate and split the contours of a +:class:`~raygeo.geo.Geometry`. They never move existing points: gaps are +bridged with a straight segment between two existing end points, so the +bounding box of the result never grows. All distances are expressed in +the units of the geometry passed in; callers that work on normalized +workpiece geometry should scale it to millimetres first. +""" + +import math +from collections.abc import Iterator + +from raygeo.geo import Geometry, Move + +Point = tuple[float, float] + +_CLOSED_EPS = 1e-6 +_DUPLICATE_SAMPLES = 32 + + +def _start_point(contour: Geometry) -> Point: + first = contour.data[0] + return first.end[0], first.end[1] + + +def _end_point(contour: Geometry) -> Point: + x, y, _z = contour.get_last_point() + return x, y + + +def _distance(a: Point, b: Point) -> float: + return math.hypot(a[0] - b[0], a[1] - b[1]) + + +def _segment_count(contour: Geometry) -> int: + return sum(1 for cmd in contour.data if not isinstance(cmd, Move)) + + +def _drawn_contours(geo: Geometry) -> list[Geometry]: + """Splits into contours, dropping those that draw nothing.""" + if geo.is_empty(): + return [] + return [c for c in geo.split_into_contours() if _segment_count(c) > 0] + + +def _is_open(contour: Geometry) -> bool: + return not contour.is_closed(_CLOSED_EPS) + + +def _close_if_near(contour: Geometry, tolerance: float) -> bool: + """ + Closes an open contour of at least two segments in place when its + start and end are within the tolerance. Returns True if it closed it. + """ + if not _is_open(contour) or _segment_count(contour) < 2: + return False + start = _start_point(contour) + if _distance(start, _end_point(contour)) > tolerance: + return False + contour.line_to(start[0], start[1]) + return True + + +def _concat(contours: list[Geometry]) -> Geometry: + result = Geometry() + for contour in contours: + result.extend(contour) + return result + + +def close_open_contours( + geo: Geometry, tolerance: float +) -> tuple[Geometry, int]: + """ + Closes every open contour whose start and end point lie within the + tolerance of each other. + + Args: + geo: The geometry to process. It is not modified. + tolerance: The largest gap that is closed. + + Returns: + A tuple of the new geometry and the number of closed contours. + """ + contours = [c.copy() for c in _drawn_contours(geo)] + closed = sum(1 for c in contours if _close_if_near(c, tolerance)) + return _concat(contours), closed + + +class _EndpointGrid: + """A spatial hash of open contour end points for neighbour lookups.""" + + def __init__(self, cell_size: float): + self._cell = max(cell_size, 1e-9) + self._cells: dict[tuple[int, int], set[tuple[int, int]]] = {} + self._points: dict[tuple[int, int], Point] = {} + + def _key(self, p: Point) -> tuple[int, int]: + return math.floor(p[0] / self._cell), math.floor(p[1] / self._cell) + + def add(self, index: int, end: int, p: Point): + self._points[(index, end)] = p + self._cells.setdefault(self._key(p), set()).add((index, end)) + + def remove_contour(self, index: int): + for end in (0, 1): + p = self._points.pop((index, end), None) + if p is not None: + self._cells[self._key(p)].discard((index, end)) + + def _neighbours(self, p: Point) -> Iterator[tuple[int, int]]: + kx, ky = self._key(p) + for dx in (-1, 0, 1): + for dy in (-1, 0, 1): + yield from self._cells.get((kx + dx, ky + dy), ()) + + def nearest(self, p: Point, tolerance: float) -> tuple[int, int] | None: + best: tuple[int, int] | None = None + best_dist = tolerance + for entry in sorted(self._neighbours(p)): + d = _distance(p, self._points[entry]) + if d <= best_dist: + best, best_dist = entry, d + return best + + +def _append_contour(chain: Geometry, piece: Geometry): + """Appends a piece to the end of a chain, bridging any gap.""" + start = _start_point(piece) + if _distance(_end_point(chain), start) > 0.0: + chain.line_to(start[0], start[1]) + chain.extend(piece.filter(set(range(1, len(piece.data))))) + + +def _grow_tail( + chain: Geometry, + contours: list[Geometry], + grid: _EndpointGrid, + tolerance: float, + consumed: set[int], +) -> int: + """ + Keeps appending the nearest open contour to the chain's end, and + records the appended contours in ``consumed``. + """ + joins = 0 + while True: + match = grid.nearest(_end_point(chain), tolerance) + if match is None: + return joins + index, end = match + grid.remove_contour(index) + consumed.add(index) + piece = contours[index].copy() + if end == 1: + piece.reverse_contour() + _append_contour(chain, piece) + joins += 1 + + +def join_open_contours( + geo: Geometry, tolerance: float +) -> tuple[Geometry, int]: + """ + Joins open contours whose end points meet within the tolerance into + longer contours, reversing contours where needed. A joined contour + whose own ends then meet within the tolerance is closed. Closed + contours are left untouched. + + Args: + geo: The geometry to process. It is not modified. + tolerance: The largest gap between two end points that is joined. + + Returns: + A tuple of the new geometry and the number of joins made. + """ + contours = _drawn_contours(geo) + grid = _EndpointGrid(tolerance) + open_indices = [i for i, c in enumerate(contours) if _is_open(c)] + for i in open_indices: + grid.add(i, 0, _start_point(contours[i])) + grid.add(i, 1, _end_point(contours[i])) + + chains: dict[int, Geometry] = {} + consumed: set[int] = set() + joins = 0 + for i in open_indices: + if i in consumed: + continue + grid.remove_contour(i) + chain = contours[i].copy() + joins += _grow_tail(chain, contours, grid, tolerance, consumed) + chain.reverse_contour() + joins += _grow_tail(chain, contours, grid, tolerance, consumed) + chain.reverse_contour() + _close_if_near(chain, tolerance) + chains[i] = chain + + result = Geometry() + for i, contour in enumerate(contours): + if i in chains: + result.extend(chains[i]) + elif i not in consumed: + result.extend(contour) + return result, joins + + +def _sample_points(contour: Geometry) -> list[Point]: + """The contour's vertices plus evenly spaced points along it.""" + points = [(cmd.end[0], cmd.end[1]) for cmd in contour.data] + length = contour.distance() + if length > 0.0: + step = length / _DUPLICATE_SAMPLES + distances = [step * k for k in range(_DUPLICATE_SAMPLES + 1)] + for _idx, _t, p in contour.get_positions_at_distances(distances): + points.append((p[0], p[1])) + return points + + +def _covers(target: Geometry, points: list[Point], tolerance: float) -> bool: + for x, y in points: + hit = target.find_closest_point(x, y) + if hit is None or _distance((x, y), hit[2]) > tolerance: + return False + return True + + +def _rects_match(a: Geometry, b: Geometry, tolerance: float) -> bool: + return all(abs(va - vb) <= tolerance for va, vb in zip(a.rect(), b.rect())) + + +def _contours_match(a: Geometry, b: Geometry, tolerance: float) -> bool: + """ + True if two contours trace the same path within the tolerance, + regardless of their direction or start point. + """ + if _is_open(a) != _is_open(b): + return False + if not _rects_match(a, b, tolerance): + return False + return _covers(b, _sample_points(a), tolerance) and _covers( + a, _sample_points(b), tolerance + ) + + +def _length_slack(tolerance: float) -> float: + return 4.0 * tolerance + 1e-9 + + +def remove_duplicate_contours( + geo: Geometry, tolerance: float +) -> tuple[Geometry, int]: + """ + Removes contours that duplicate an earlier contour within the + tolerance, regardless of direction or start point. The first + occurrence is kept and the order of the remaining contours is + preserved. + + Args: + geo: The geometry to process. It is not modified. + tolerance: The largest distance between two matching paths. + + Returns: + A tuple of the new geometry and the number of removed contours. + """ + contours = _drawn_contours(geo) + lengths = [c.distance() for c in contours] + order = sorted(range(len(contours)), key=lambda i: lengths[i]) + duplicates: set[int] = set() + slack = _length_slack(tolerance) + for pos, i in enumerate(order): + if i in duplicates: + continue + for j in order[pos + 1 :]: + if lengths[j] - lengths[i] > slack: + break + if j in duplicates: + continue + if _contours_match(contours[i], contours[j], tolerance): + duplicates.add(max(i, j)) + if max(i, j) == i: + break + kept = [c for i, c in enumerate(contours) if i not in duplicates] + return _concat(kept), len(duplicates) + + +def geometries_match(a: Geometry, b: Geometry, tolerance: float) -> bool: + """ + True if both geometries consist of the same contours within the + tolerance, in any order, direction or start point. Empty geometries + never match. + """ + contours_a = _drawn_contours(a) + contours_b = _drawn_contours(b) + if not contours_a or len(contours_a) != len(contours_b): + return False + unmatched = list(contours_b) + for contour in contours_a: + found = next( + ( + other + for other in unmatched + if _contours_match(contour, other, tolerance) + ), + None, + ) + if found is None: + return False + unmatched.remove(found) + return True + + +def split_contours(geo: Geometry) -> list[Geometry]: + """ + Splits a geometry into one geometry per contour. Unlike splitting + into connected components, holes become parts of their own and open + contours are kept. + """ + return [c.copy() for c in _drawn_contours(geo)] diff --git a/rayforge/doceditor/editor.py b/rayforge/doceditor/editor.py index 16e8c1b32..8f0788770 100644 --- a/rayforge/doceditor/editor.py +++ b/rayforge/doceditor/editor.py @@ -26,6 +26,7 @@ from .group_cmd import GroupCmd from .layer_cmd import LayerCmd from .layout_cmd import LayoutCmd +from .path_cleanup_cmd import PathCleanupCmd from .split_cmd import SplitCmd from .step_cmd import StepCmd from .stock_cmd import StockCmd @@ -151,6 +152,7 @@ def __init__( self.group = GroupCmd(self, self.task_manager) self.layer = LayerCmd(self) self.layout = LayoutCmd(self, self.task_manager) + self.path_cleanup = PathCleanupCmd(self) self.split = SplitCmd(self) self.stock = StockCmd(self) self.step = StepCmd(self) diff --git a/rayforge/doceditor/path_cleanup_cmd.py b/rayforge/doceditor/path_cleanup_cmd.py new file mode 100644 index 000000000..2fc3ef152 --- /dev/null +++ b/rayforge/doceditor/path_cleanup_cmd.py @@ -0,0 +1,232 @@ +import logging +from collections.abc import Callable +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from raygeo.geo import Geometry, Matrix + +from ..core.item import DocItem +from ..core.path_cleanup import ( + close_open_contours, + geometries_match, + join_open_contours, + remove_duplicate_contours, +) +from ..core.undo import ChangePropertyCommand, ListItemCommand +from ..core.workpiece import WorkPiece +from .split_cmd import ContourSplitStrategy + +if TYPE_CHECKING: + from .editor import DocEditor + +logger = logging.getLogger(__name__) + +DEFAULT_DUPLICATE_TOLERANCE_MM = 0.01 + +GeometryOperation = Callable[[Geometry, float], tuple[Geometry, int]] + + +def _mm_scale(workpiece: WorkPiece) -> tuple[float, float]: + """ + The factors that turn the workpiece's normalized geometry into + millimetres. A degenerate axis keeps a factor of 1 so the scaling can + be undone. + """ + width, height = workpiece.size + return ( + width if width > 1e-9 else 1.0, + height if height > 1e-9 else 1.0, + ) + + +def _is_editable(workpiece: WorkPiece) -> bool: + """ + Workpieces generated from a geometry provider such as a sketch are + edited at their source, so path clean-up leaves them alone. + """ + if workpiece.geometry_provider_uid: + return False + boundaries = workpiece.boundaries + return boundaries is not None and not boundaries.is_empty() + + +def _world_geometry(workpiece: WorkPiece) -> Geometry | None: + boundaries = workpiece.boundaries + if boundaries is None or boundaries.is_empty(): + return None + geo = boundaries.copy() + geo.transform(workpiece.get_world_transform()) + return geo + + +def _rects_overlap(a: Geometry, b: Geometry, tolerance: float) -> bool: + return all(abs(va - vb) <= tolerance for va, vb in zip(a.rect(), b.rect())) + + +class PathCleanupCmd: + """ + Clean-up operations on the vector paths of workpieces: closing, + joining, removing duplicates and breaking apart. Tolerances are given + in millimetres, independent of the workpiece's scale. + """ + + def __init__(self, editor: "DocEditor"): + self._editor = editor + + def close_paths( + self, workpieces: list[WorkPiece], tolerance_mm: float + ) -> int: + """ + Closes open contours whose start and end are within the tolerance. + + Returns: + The number of closed contours. + """ + return self._apply( + workpieces, close_open_contours, tolerance_mm, _("Close paths") + ) + + def join_paths( + self, workpieces: list[WorkPiece], tolerance_mm: float + ) -> int: + """ + Joins open contours whose end points meet within the tolerance. + + Returns: + The number of joins made. + """ + return self._apply( + workpieces, join_open_contours, tolerance_mm, _("Join paths") + ) + + def delete_duplicates( + self, + workpieces: list[WorkPiece], + tolerance_mm: float = DEFAULT_DUPLICATE_TOLERANCE_MM, + ) -> int: + """ + Removes duplicate contours inside each workpiece, and removes + workpieces that are an exact copy of an earlier one in the list + lying on top of it. + + Returns: + The number of removed contours and workpieces. + """ + duplicates = self._find_duplicate_workpieces(workpieces, tolerance_mm) + remaining = [wp for wp in workpieces if wp not in duplicates] + changes = self._compute_changes( + remaining, remove_duplicate_contours, tolerance_mm + ) + if not duplicates and not changes: + return 0 + + name = _("Delete duplicates") + removed = len(duplicates) + with self._editor.history_manager.transaction(name) as t: + for workpiece in duplicates: + t.execute( + ListItemCommand( + owner_obj=workpiece.parent, + item=workpiece, + undo_command="add_child", + redo_command="remove_child", + name=name, + ) + ) + for workpiece, new_geo, count in changes: + t.execute(self._boundaries_command(workpiece, new_geo, name)) + removed += count + return removed + + def break_apart(self, workpieces: list[WorkPiece]) -> list[DocItem]: + """ + Splits each workpiece into one workpiece per contour. + + Returns: + The newly created workpieces. + """ + return self._editor.split.split_items( + workpieces, strategy=ContourSplitStrategy(), name=_("Break apart") + ) + + def _apply( + self, + workpieces: list[WorkPiece], + operation: GeometryOperation, + tolerance_mm: float, + name: str, + ) -> int: + changes = self._compute_changes(workpieces, operation, tolerance_mm) + if not changes: + return 0 + total = 0 + with self._editor.history_manager.transaction(name) as t: + for workpiece, new_geo, count in changes: + t.execute(self._boundaries_command(workpiece, new_geo, name)) + total += count + return total + + def _compute_changes( + self, + workpieces: list[WorkPiece], + operation: GeometryOperation, + tolerance_mm: float, + ) -> list[tuple[WorkPiece, Geometry, int]]: + """ + Runs the operation on each workpiece's geometry in millimetres and + returns the normalized results of the workpieces it changed. + """ + changes = [] + for workpiece in workpieces: + if not _is_editable(workpiece): + continue + boundaries = workpiece.boundaries + assert boundaries is not None + sx, sy = _mm_scale(workpiece) + geo_mm = boundaries.copy() + geo_mm.transform(Matrix.scale(sx, sy)) + new_geo, count = operation(geo_mm, tolerance_mm) + if count == 0: + continue + new_geo.transform(Matrix.scale(1.0 / sx, 1.0 / sy)) + changes.append((workpiece, new_geo, count)) + return changes + + def _boundaries_command( + self, workpiece: WorkPiece, new_geo: Geometry, name: str + ) -> ChangePropertyCommand: + def _on_changed(): + workpiece.clear_render_cache() + workpiece.updated.send(workpiece) + + return ChangePropertyCommand( + target=workpiece, + property_name="_edited_boundaries", + new_value=new_geo, + old_value=workpiece._edited_boundaries, + on_change_callback=_on_changed, + name=name, + ) + + def _find_duplicate_workpieces( + self, workpieces: list[WorkPiece], tolerance_mm: float + ) -> list[WorkPiece]: + """ + Finds workpieces whose world-space geometry matches an earlier + workpiece in the list. + """ + kept: list[tuple[WorkPiece, Geometry]] = [] + duplicates: list[WorkPiece] = [] + for workpiece in workpieces: + geo = _world_geometry(workpiece) + if geo is None or workpiece.parent is None: + continue + if any( + _rects_overlap(geo, other, tolerance_mm) + and geometries_match(geo, other, tolerance_mm) + for _wp, other in kept + ): + duplicates.append(workpiece) + else: + kept.append((workpiece, geo)) + return duplicates diff --git a/rayforge/doceditor/split_cmd.py b/rayforge/doceditor/split_cmd.py index 12f08883e..03f4b9282 100644 --- a/rayforge/doceditor/split_cmd.py +++ b/rayforge/doceditor/split_cmd.py @@ -7,6 +7,7 @@ from raygeo.geo import Geometry from ..core.item import DocItem +from ..core.path_cleanup import split_contours from ..core.undo import ListItemCommand from ..core.workpiece import WorkPiece @@ -49,6 +50,19 @@ def calculate_fragments(self, workpiece: "WorkPiece") -> list[Geometry]: return workpiece.boundaries.split_into_components() +class ContourSplitStrategy(SplitStrategy): + """ + Splits a workpiece into one fragment per contour ("Break Apart"). + Unlike the connectivity split, holes become separate pieces and open + paths are kept. + """ + + def calculate_fragments(self, workpiece: "WorkPiece") -> list[Geometry]: + if not workpiece.boundaries or workpiece.boundaries.is_empty(): + return [] + return split_contours(workpiece.boundaries) + + class SplitCmd: """Handles splitting of document items.""" @@ -59,6 +73,7 @@ def split_items( self, items: list[WorkPiece], strategy: SplitStrategy | None = None, + name: str | None = None, ) -> list[DocItem]: """ Splits the provided items into multiple fragments based on the given @@ -69,6 +84,7 @@ def split_items( items: The list of items to split. strategy: The strategy to use for calculating fragments. Defaults to splitting disjoint components. + name: The name of the undo step. Defaults to "Split item(s)". Returns: A list of the newly created items. @@ -81,7 +97,7 @@ def split_items( history = self._editor.history_manager newly_created_items = [] - with history.transaction(_("Split item(s)")) as t: + with history.transaction(name or _("Split item(s)")) as t: for item in items: # Capture the parent before any modification/removal occurs. # Executing remove_cmd may set item.parent to None. diff --git a/tests/core/test_path_cleanup.py b/tests/core/test_path_cleanup.py new file mode 100644 index 000000000..8169a244e --- /dev/null +++ b/tests/core/test_path_cleanup.py @@ -0,0 +1,314 @@ +import math + +import pytest +from raygeo.geo import Geometry + +from rayforge.core.path_cleanup import ( + close_open_contours, + geometries_match, + join_open_contours, + remove_duplicate_contours, + split_contours, +) + + +def _polyline(points, close=False) -> Geometry: + geo = Geometry() + geo.move_to(*points[0]) + for p in points[1:]: + geo.line_to(*p) + if close: + geo.line_to(*points[0]) + return geo + + +def _concat(*geos: Geometry) -> Geometry: + result = Geometry() + for geo in geos: + result.extend(geo) + return result + + +def _contours(geo: Geometry) -> list[Geometry]: + return geo.split_into_contours() + + +def _start(geo: Geometry) -> tuple[float, float]: + first = next(iter(geo.iter_typed_commands())) + return first.end[0], first.end[1] + + +def _end(geo: Geometry) -> tuple[float, float]: + x, y, _z = geo.get_last_point() + return x, y + + +def _dist(a, b) -> float: + return math.hypot(a[0] - b[0], a[1] - b[1]) + + +SQUARE = [(0, 0), (10, 0), (10, 10), (0, 10)] + + +class TestCloseOpenContours: + def test_closes_contour_within_tolerance(self): + geo = _polyline(SQUARE + [(0, 0.05)]) + result, count = close_open_contours(geo, 0.1) + assert count == 1 + contours = _contours(result) + assert len(contours) == 1 + assert contours[0].is_closed() + + def test_leaves_contour_outside_tolerance_open(self): + geo = _polyline(SQUARE + [(0, 0.5)]) + result, count = close_open_contours(geo, 0.1) + assert count == 0 + assert not _contours(result)[0].is_closed() + + def test_does_not_move_existing_points(self): + geo = _polyline(SQUARE + [(0, 0.05)]) + result, _count = close_open_contours(geo, 0.1) + assert result.rect() == pytest.approx(geo.rect()) + + def test_already_closed_contour_is_untouched(self): + geo = _polyline(SQUARE, close=True) + result, count = close_open_contours(geo, 0.1) + assert count == 0 + assert len(result.data) == len(geo.data) + + def test_handles_each_contour_separately(self): + geo = _concat( + _polyline(SQUARE + [(0, 0.05)]), + _polyline([(20, 0), (30, 0), (30, 10), (20, 3)]), + _polyline([(40, 0), (50, 0), (50, 10), (40, 0.02)]), + ) + result, count = close_open_contours(geo, 0.1) + assert count == 2 + closed = [c.is_closed() for c in _contours(result)] + assert closed == [True, False, True] + + def test_ignores_degenerate_single_segment(self): + geo = _polyline([(0, 0), (0.05, 0)]) + _result, count = close_open_contours(geo, 0.1) + assert count == 0 + + def test_empty_geometry(self): + result, count = close_open_contours(Geometry(), 0.1) + assert count == 0 + assert result.is_empty() + + +class TestJoinOpenContours: + def test_joins_two_pieces_in_order(self): + geo = _concat( + _polyline([(0, 0), (10, 0), (10, 10)]), + _polyline([(10, 10.05), (0, 10), (0, 0.05)]), + ) + result, joins = join_open_contours(geo, 0.1) + assert joins == 1 + contours = _contours(result) + assert len(contours) == 1 + assert contours[0].is_closed() + + def test_joins_reversed_piece(self): + geo = _concat( + _polyline([(0, 0), (10, 0)]), + _polyline([(20, 0), (10.02, 0)]), + ) + result, joins = join_open_contours(geo, 0.1) + assert joins == 1 + contours = _contours(result) + assert len(contours) == 1 + ends = {_start(contours[0]), _end(contours[0])} + assert ends == {(0.0, 0.0), (20.0, 0.0)} + + def test_joins_pieces_out_of_order(self): + geo = _concat( + _polyline([(0, 0), (10, 0)]), + _polyline([(20, 0), (30, 0)]), + _polyline([(10, 0), (20, 0)]), + ) + result, joins = join_open_contours(geo, 0.1) + assert joins == 2 + contours = _contours(result) + assert len(contours) == 1 + assert result.distance() == pytest.approx(30.0) + + def test_joins_at_the_start_of_a_piece(self): + geo = _concat( + _polyline([(10, 0), (20, 0)]), + _polyline([(0, 0), (10, 0)]), + ) + result, joins = join_open_contours(geo, 0.1) + assert joins == 1 + contours = _contours(result) + assert len(contours) == 1 + assert {_start(contours[0]), _end(contours[0])} == { + (0.0, 0.0), + (20.0, 0.0), + } + + def test_keeps_pieces_apart_beyond_tolerance(self): + geo = _concat( + _polyline([(0, 0), (10, 0)]), + _polyline([(11, 0), (20, 0)]), + ) + result, joins = join_open_contours(geo, 0.1) + assert joins == 0 + assert len(_contours(result)) == 2 + + def test_leaves_closed_contours_alone(self): + square = _polyline(SQUARE, close=True) + line = _polyline([(0, 0), (-10, 0)]) + geo = _concat(square, line) + result, joins = join_open_contours(geo, 0.1) + assert joins == 0 + assert len(_contours(result)) == 2 + + def test_closes_single_piece_whose_ends_meet(self): + geo = _polyline(SQUARE + [(0, 0.05)]) + result, _joins = join_open_contours(geo, 0.1) + assert _contours(result)[0].is_closed() + + def test_bridge_does_not_change_bounds(self): + geo = _concat( + _polyline([(0, 0), (10, 0), (10, 10)]), + _polyline([(10.05, 10), (0, 10), (0, 0)]), + ) + result, _joins = join_open_contours(geo, 0.1) + assert result.rect() == pytest.approx(geo.rect()) + + def test_many_segments_form_one_closed_outline(self): + pts = [ + (10 * math.cos(a), 10 * math.sin(a)) + for a in (2 * math.pi * i / 60 for i in range(60)) + ] + pieces = [] + for i in range(60): + a, b = pts[i], pts[(i + 1) % 60] + if i % 2: + a, b = b, a + pieces.append(_polyline([a, b])) + geo = _concat(*reversed(pieces)) + result, joins = join_open_contours(geo, 0.01) + assert joins == 59 + contours = _contours(result) + assert len(contours) == 1 + assert contours[0].is_closed() + + +class TestRemoveDuplicateContours: + def test_removes_identical_copy(self): + geo = _concat( + _polyline(SQUARE, close=True), _polyline(SQUARE, close=True) + ) + result, removed = remove_duplicate_contours(geo, 0.01) + assert removed == 1 + assert len(_contours(result)) == 1 + + def test_removes_reversed_copy(self): + geo = _concat( + _polyline(SQUARE, close=True), + _polyline(list(reversed(SQUARE)), close=True), + ) + _result, removed = remove_duplicate_contours(geo, 0.01) + assert removed == 1 + + def test_removes_copy_with_other_start_point(self): + shifted = SQUARE[2:] + SQUARE[:2] + geo = _concat( + _polyline(SQUARE, close=True), _polyline(shifted, close=True) + ) + _result, removed = remove_duplicate_contours(geo, 0.01) + assert removed == 1 + + def test_removes_open_reversed_copy(self): + line = [(0, 0), (5, 5), (10, 0)] + geo = _concat(_polyline(line), _polyline(list(reversed(line)))) + _result, removed = remove_duplicate_contours(geo, 0.01) + assert removed == 1 + + def test_keeps_shapes_that_only_overlap_partly(self): + geo = _concat( + _polyline(SQUARE, close=True), + _polyline([(0, 0), (10, 0), (10, 10)]), + ) + result, removed = remove_duplicate_contours(geo, 0.01) + assert removed == 0 + assert len(_contours(result)) == 2 + + def test_keeps_nested_shapes(self): + inner = [(2, 2), (8, 2), (8, 8), (2, 8)] + geo = _concat( + _polyline(SQUARE, close=True), _polyline(inner, close=True) + ) + _result, removed = remove_duplicate_contours(geo, 0.01) + assert removed == 0 + + def test_tolerance_applies(self): + nudged = [(x + 0.005, y) for x, y in SQUARE] + geo = _concat( + _polyline(SQUARE, close=True), _polyline(nudged, close=True) + ) + assert remove_duplicate_contours(geo, 0.01)[1] == 1 + assert remove_duplicate_contours(geo, 0.001)[1] == 0 + + def test_keeps_first_occurrence_and_order(self): + other = [(20, 0), (30, 0), (30, 10)] + geo = _concat( + _polyline(SQUARE, close=True), + _polyline(other), + _polyline(SQUARE, close=True), + ) + result, removed = remove_duplicate_contours(geo, 0.01) + assert removed == 1 + contours = _contours(result) + assert _start(contours[0]) == (0.0, 0.0) + assert _start(contours[1]) == (20.0, 0.0) + + def test_removes_triple_copies(self): + geo = _concat(*(_polyline(SQUARE, close=True) for _ in range(3))) + _result, removed = remove_duplicate_contours(geo, 0.01) + assert removed == 2 + + +class TestGeometriesMatch: + def test_same_shapes_in_other_order_match(self): + a = _concat( + _polyline(SQUARE, close=True), _polyline([(20, 0), (30, 0)]) + ) + b = _concat( + _polyline([(30, 0), (20, 0)]), _polyline(SQUARE, close=True) + ) + assert geometries_match(a, b, 0.01) + + def test_different_shapes_do_not_match(self): + a = _polyline(SQUARE, close=True) + b = _polyline([(0, 0), (10, 0), (10, 11), (0, 10)], close=True) + assert not geometries_match(a, b, 0.01) + + def test_extra_contour_does_not_match(self): + a = _polyline(SQUARE, close=True) + b = _concat( + _polyline(SQUARE, close=True), _polyline([(20, 0), (30, 0)]) + ) + assert not geometries_match(a, b, 0.01) + + def test_empty_geometries_do_not_match(self): + assert not geometries_match(Geometry(), Geometry(), 0.01) + + +class TestSplitContours: + def test_splits_every_contour_including_holes_and_open_paths(self): + inner = [(2, 2), (8, 2), (8, 8), (2, 8)] + geo = _concat( + _polyline(SQUARE, close=True), + _polyline(inner, close=True), + _polyline([(20, 0), (30, 0)]), + ) + parts = split_contours(geo) + assert len(parts) == 3 + assert parts[2].distance() == pytest.approx(10.0) + + def test_single_contour(self): + assert len(split_contours(_polyline(SQUARE, close=True))) == 1 diff --git a/tests/doceditor/test_path_cleanup_cmd.py b/tests/doceditor/test_path_cleanup_cmd.py new file mode 100644 index 000000000..8c8ce34c6 --- /dev/null +++ b/tests/doceditor/test_path_cleanup_cmd.py @@ -0,0 +1,195 @@ +import pytest +from raygeo.geo import Geometry, Matrix + +from rayforge.core.source_asset_segment import SourceAssetSegment +from rayforge.core.vectorization_spec import PassthroughSpec +from rayforge.core.workpiece import WorkPiece +from rayforge.doceditor.path_cleanup_cmd import PathCleanupCmd +from rayforge.doceditor.split_cmd import ContourSplitStrategy + +WIDTH_MM = 100.0 +HEIGHT_MM = 50.0 + + +@pytest.fixture +def cleanup_cmd(doc_editor): + return PathCleanupCmd(doc_editor) + + +def _mm_polyline(points_mm, close=False) -> Geometry: + """A polyline given in mm, normalized to the 100x50 mm test box.""" + geo = Geometry() + pts = [(x / WIDTH_MM, y / HEIGHT_MM) for x, y in points_mm] + geo.move_to(*pts[0]) + for p in pts[1:]: + geo.line_to(*p) + if close: + geo.line_to(*pts[0]) + return geo + + +def _concat(*geos: Geometry) -> Geometry: + result = Geometry() + for geo in geos: + result.extend(geo) + return result + + +def _add_workpiece(doc_editor, norm_geo: Geometry, name="Logo", pos=(0, 0)): + segment = SourceAssetSegment( + source_asset_uid="dummy", + pristine_geometry=norm_geo, + vectorization_spec=PassthroughSpec(), + normalization_matrix=Matrix.identity(), + ) + wp = WorkPiece(name=name, source_segment=segment) + wp.natural_width_mm = WIDTH_MM + wp.natural_height_mm = HEIGHT_MM + wp.set_size(WIDTH_MM, HEIGHT_MM) + wp.pos = pos + doc_editor.doc.active_layer.add_child(wp) + return wp + + +def _contours(wp: WorkPiece) -> list[Geometry]: + assert wp.boundaries is not None + return wp.boundaries.split_into_contours() + + +# A 0.3 mm wide shape inset into the 100x50 mm box. The bounding box +# corners at (0, 0) and (100, 50) keep the normalization stable. +FRAME = [(0, 0), (100, 0), (100, 50), (0, 50)] +OPEN_SQUARE_MM = [(20, 10), (40, 10), (40, 30), (20, 30), (20, 10.05)] + + +class TestClosePaths: + def test_tolerance_is_in_millimetres(self, doc_editor, cleanup_cmd): + geo = _concat( + _mm_polyline(FRAME, close=True), _mm_polyline(OPEN_SQUARE_MM) + ) + wp = _add_workpiece(doc_editor, geo) + + assert cleanup_cmd.close_paths([wp], tolerance_mm=0.01) == 0 + assert not _contours(wp)[1].is_closed() + + assert cleanup_cmd.close_paths([wp], tolerance_mm=0.1) == 1 + assert _contours(wp)[1].is_closed() + + def test_is_one_undo_step(self, doc_editor, cleanup_cmd): + geo = _mm_polyline(OPEN_SQUARE_MM) + wp = _add_workpiece(doc_editor, geo) + history = doc_editor.history_manager + depth = len(history.undo_stack) + + cleanup_cmd.close_paths([wp], tolerance_mm=0.1) + assert len(history.undo_stack) == depth + 1 + assert _contours(wp)[0].is_closed() + + history.undo() + assert not _contours(wp)[0].is_closed() + + def test_no_change_adds_no_undo_step(self, doc_editor, cleanup_cmd): + wp = _add_workpiece(doc_editor, _mm_polyline(FRAME, close=True)) + history = doc_editor.history_manager + depth = len(history.undo_stack) + + assert cleanup_cmd.close_paths([wp], tolerance_mm=0.1) == 0 + assert len(history.undo_stack) == depth + + def test_keeps_size_and_position(self, doc_editor, cleanup_cmd): + wp = _add_workpiece(doc_editor, _mm_polyline(OPEN_SQUARE_MM)) + size, pos = wp.size, wp.pos + cleanup_cmd.close_paths([wp], tolerance_mm=0.1) + assert wp.size == pytest.approx(size) + assert wp.pos == pytest.approx(pos) + + def test_skips_sketch_based_workpieces(self, doc_editor, cleanup_cmd): + wp = _add_workpiece(doc_editor, _mm_polyline(OPEN_SQUARE_MM)) + wp.geometry_provider_uid = "some-sketch" + assert cleanup_cmd.close_paths([wp], tolerance_mm=0.1) == 0 + assert wp._edited_boundaries is None + + +class TestJoinPaths: + def test_joins_pieces_within_tolerance(self, doc_editor, cleanup_cmd): + geo = _concat( + _mm_polyline(FRAME, close=True), + _mm_polyline([(20, 10), (40, 10), (40, 30)]), + _mm_polyline([(20, 10), (20, 30), (40, 30.05)]), + ) + wp = _add_workpiece(doc_editor, geo) + + assert cleanup_cmd.join_paths([wp], tolerance_mm=0.1) == 1 + contours = _contours(wp) + assert len(contours) == 2 + assert contours[1].is_closed() + + doc_editor.history_manager.undo() + assert len(_contours(wp)) == 3 + + +class TestDeleteDuplicates: + def test_removes_duplicate_contour(self, doc_editor, cleanup_cmd): + geo = _concat( + _mm_polyline(FRAME, close=True), + _mm_polyline(OPEN_SQUARE_MM), + _mm_polyline(list(reversed(OPEN_SQUARE_MM))), + ) + wp = _add_workpiece(doc_editor, geo) + + assert cleanup_cmd.delete_duplicates([wp]) == 1 + assert len(_contours(wp)) == 2 + + doc_editor.history_manager.undo() + assert len(_contours(wp)) == 3 + + def test_removes_stacked_copy_of_workpiece(self, doc_editor, cleanup_cmd): + layer = doc_editor.doc.active_layer + geo = _mm_polyline(FRAME, close=True) + first = _add_workpiece(doc_editor, geo, name="A", pos=(10, 10)) + copy = _add_workpiece(doc_editor, geo.copy(), name="B", pos=(10, 10)) + moved = _add_workpiece(doc_editor, geo.copy(), name="C", pos=(50, 10)) + + assert cleanup_cmd.delete_duplicates([first, copy, moved]) == 1 + assert first in layer.children + assert copy not in layer.children + assert moved in layer.children + + doc_editor.history_manager.undo() + assert copy in layer.children + + def test_nothing_to_delete(self, doc_editor, cleanup_cmd): + wp = _add_workpiece(doc_editor, _mm_polyline(FRAME, close=True)) + depth = len(doc_editor.history_manager.undo_stack) + assert cleanup_cmd.delete_duplicates([wp]) == 0 + assert len(doc_editor.history_manager.undo_stack) == depth + + +class TestBreakApart: + def test_every_contour_becomes_a_workpiece(self, doc_editor, cleanup_cmd): + layer = doc_editor.doc.active_layer + inner = [(10, 10), (90, 10), (90, 40), (10, 40)] + geo = _concat( + _mm_polyline(FRAME, close=True), + _mm_polyline(inner, close=True), + _mm_polyline([(20, 20), (80, 20)]), + ) + wp = _add_workpiece(doc_editor, geo) + + new_items = cleanup_cmd.break_apart([wp]) + assert len(new_items) == 3 + assert wp not in layer.children + sizes = sorted(round(item.size[0]) for item in new_items) + assert sizes == [60, 80, 100] + + doc_editor.history_manager.undo() + assert wp in layer.children + + def test_contour_strategy_keeps_open_paths(self, doc_editor): + geo = _concat( + _mm_polyline(FRAME, close=True), + _mm_polyline([(20, 20), (80, 20)]), + ) + wp = _add_workpiece(doc_editor, geo) + fragments = ContourSplitStrategy().calculate_fragments(wp) + assert len(fragments) == 2 From 63748b2734637ca7017e376990fdde3e65e05d01 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Fri, 9 Oct 2026 12:57:42 +0200 Subject: [PATCH 027/104] feat(ui): expose path clean-up tools in Object and context menus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Break Apart, Close Paths…, Join Open Paths… and Delete Duplicates to the Object menu and a Clean Up Paths submenu to the canvas context menu. Close and join ask for a tolerance in a small dialog that remembers the last value per tool for the session; each tool reports its result in a toast. Includes translations for all shipped languages, a documentation page in every website language and a changelog entry. Closes #516 --- CHANGELOG.md | 5 + rayforge/ui_gtk/actions.py | 101 +++++++++++++++++- rayforge/ui_gtk/canvas2d/context_menu.py | 18 ++++ .../ui_gtk/doceditor/path_tolerance_dialog.py | 68 ++++++++++++ rayforge/ui_gtk/main_menu.py | 7 ++ .../doceditor/test_path_tolerance_dialog.py | 60 +++++++++++ website/docs/features/path-cleanup.md | 53 +++++++++ website/docs/ui/canvas-tools.md | 3 + .../current/features/path-cleanup.md | 51 +++++++++ .../current/ui/canvas-tools.md | 3 + .../current/features/path-cleanup.md | 50 +++++++++ .../current/ui/canvas-tools.md | 3 + .../current/features/path-cleanup.md | 51 +++++++++ .../current/ui/canvas-tools.md | 3 + .../current/features/path-cleanup.md | 47 ++++++++ .../current/ui/canvas-tools.md | 3 + .../current/features/path-cleanup.md | 51 +++++++++ .../current/ui/canvas-tools.md | 3 + .../current/features/path-cleanup.md | 48 +++++++++ .../current/ui/canvas-tools.md | 3 + .../current/features/path-cleanup.md | 34 ++++++ .../current/ui/canvas-tools.md | 2 + website/sidebars.js | 1 + 23 files changed, 667 insertions(+), 1 deletion(-) create mode 100644 rayforge/ui_gtk/doceditor/path_tolerance_dialog.py create mode 100644 tests/ui_gtk/doceditor/test_path_tolerance_dialog.py create mode 100644 website/docs/features/path-cleanup.md create mode 100644 website/i18n/de/docusaurus-plugin-content-docs/current/features/path-cleanup.md create mode 100644 website/i18n/es/docusaurus-plugin-content-docs/current/features/path-cleanup.md create mode 100644 website/i18n/fr/docusaurus-plugin-content-docs/current/features/path-cleanup.md create mode 100644 website/i18n/hi/docusaurus-plugin-content-docs/current/features/path-cleanup.md create mode 100644 website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/path-cleanup.md create mode 100644 website/i18n/uk/docusaurus-plugin-content-docs/current/features/path-cleanup.md create mode 100644 website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/path-cleanup.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 283c89fe6..b18ae19e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 the page size (#517) - Import: GIF, TIFF and WebP bitmaps, traced like PNG. Animated GIFs and multi-page TIFFs import their first frame or page (#517) +- Path clean-up tools in the Object menu and canvas context menu: + Close Paths and Join Open Paths with a tolerance in mm, Delete + Duplicates (duplicate contours inside a workpiece and stacked copies + of whole workpieces), and Break Apart (one workpiece per contour, + holes and open paths included). Each is a single undo step (#516) - Sketcher: boolean operations. Union, Difference, Intersection and Exclude bake the selected closed regions into multi-ring polygons (outer contours counter-clockwise, hole contours clockwise), so diff --git a/rayforge/ui_gtk/actions.py b/rayforge/ui_gtk/actions.py index 4327dad2d..9bd4b26ef 100644 --- a/rayforge/ui_gtk/actions.py +++ b/rayforge/ui_gtk/actions.py @@ -1,9 +1,10 @@ import logging from collections.abc import Callable from gettext import gettext as _ +from gettext import ngettext from typing import TYPE_CHECKING, cast -from gi.repository import Gio, GLib, Gtk +from gi.repository import Adw, Gio, GLib, Gtk from ..context import get_context from ..core.group import Group @@ -20,6 +21,7 @@ PointRotationArrayDialog, ) from .doceditor.add_tabs_popover import AddTabsPopover +from .doceditor.path_tolerance_dialog import PathToleranceDialog from .doceditor.stock_properties_dialog import StockPropertiesDialog from .shared.keyboard import PRIMARY_ACCEL @@ -423,6 +425,12 @@ def register_actions(self): # Split Action self._add_action("split", self.on_split_action) + # Path Clean-Up Actions + self._add_action("break-apart", self.on_break_apart) + self._add_action("close-paths", self.on_close_paths) + self._add_action("join-paths", self.on_join_paths) + self._add_action("delete-duplicates", self.on_delete_duplicates) + # Convert to Stock Action self._add_action("convert-to-stock", self.on_convert_to_stock) @@ -550,6 +558,13 @@ def update_action_states(self, *args, **kwargs): layout_info.action.set_enabled(has_workpieces) self.actions["split"].set_enabled(bool(selected_wps)) + for name in ( + "break-apart", + "close-paths", + "join-paths", + "delete-duplicates", + ): + self.actions[name].set_enabled(bool(selected_wps)) self.actions["export-object"].set_enabled(len(selected_wps) == 1) action_extension_registry.invoke_state_update_handlers(self) @@ -798,6 +813,90 @@ def on_split_action(self, action, param): if new_items: self.win.surface.select_items(new_items) + def on_break_apart(self, action, param): + """Handler for the 'break-apart' action.""" + selected_workpieces = self.win.surface.get_selected_workpieces() + if not selected_workpieces: + return + + new_items = self.editor.path_cleanup.break_apart(selected_workpieces) + if new_items: + self.win.surface.select_items(new_items) + else: + self._show_toast(_("Nothing to break apart")) + + def on_close_paths(self, action, param): + """Handler for the 'close-paths' action: asks for a tolerance.""" + workpieces = self.win.surface.get_selected_workpieces() + if not workpieces: + return + + def _apply(tolerance: float): + count = self.editor.path_cleanup.close_paths(workpieces, tolerance) + self._show_toast( + ngettext( + "Closed {count} path", + "Closed {count} paths", + count, + ).format(count=count) + ) + + PathToleranceDialog( + heading=_("Close Paths"), + body=_( + "Closes open paths whose start and end point are closer " + "than the tolerance." + ), + apply_label=_("Close Paths"), + key="close-paths", + on_apply=_apply, + ).present(self.win) + + def on_join_paths(self, action, param): + """Handler for the 'join-paths' action: asks for a tolerance.""" + workpieces = self.win.surface.get_selected_workpieces() + if not workpieces: + return + + def _apply(tolerance: float): + count = self.editor.path_cleanup.join_paths(workpieces, tolerance) + self._show_toast( + ngettext( + "Made {count} join", + "Made {count} joins", + count, + ).format(count=count) + ) + + PathToleranceDialog( + heading=_("Join Open Paths"), + body=_( + "Joins open paths whose end points are closer than the " + "tolerance. Paths whose ends then meet are closed." + ), + apply_label=_("Join Paths"), + key="join-paths", + on_apply=_apply, + ).present(self.win) + + def on_delete_duplicates(self, action, param): + """Handler for the 'delete-duplicates' action.""" + workpieces = self.win.surface.get_selected_workpieces() + if not workpieces: + return + + count = self.editor.path_cleanup.delete_duplicates(workpieces) + self._show_toast( + ngettext( + "Deleted {count} duplicate", + "Deleted {count} duplicates", + count, + ).format(count=count) + ) + + def _show_toast(self, message: str): + self.win.toast_overlay.add_toast(Adw.Toast.new(message)) + def on_convert_to_stock(self, action, param): """Handler for the 'convert-to-stock' action.""" selected_wps = self.win.surface.get_selected_workpieces() diff --git a/rayforge/ui_gtk/canvas2d/context_menu.py b/rayforge/ui_gtk/canvas2d/context_menu.py index dae5fcb0e..9f18b437d 100644 --- a/rayforge/ui_gtk/canvas2d/context_menu.py +++ b/rayforge/ui_gtk/canvas2d/context_menu.py @@ -114,6 +114,24 @@ def _populate_standard_items(menu: Gio.Menu): # Separator menu.append_section(None, Gio.Menu.new()) + cleanup_menu = Gio.Menu.new() + cleanup_menu.append_item( + Gio.MenuItem.new(_("Break Apart"), "win.break-apart") + ) + cleanup_menu.append_item( + Gio.MenuItem.new(_("Close Paths…"), "win.close-paths") + ) + cleanup_menu.append_item( + Gio.MenuItem.new(_("Join Open Paths…"), "win.join-paths") + ) + cleanup_menu.append_item( + Gio.MenuItem.new(_("Delete Duplicates"), "win.delete-duplicates") + ) + menu.append_submenu(_("Clean Up Paths"), cleanup_menu) + + # Separator + menu.append_section(None, Gio.Menu.new()) + menu.append_item( Gio.MenuItem.new(_("Convert to Stock"), "win.convert-to-stock") ) diff --git a/rayforge/ui_gtk/doceditor/path_tolerance_dialog.py b/rayforge/ui_gtk/doceditor/path_tolerance_dialog.py new file mode 100644 index 000000000..4673e6f35 --- /dev/null +++ b/rayforge/ui_gtk/doceditor/path_tolerance_dialog.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import logging +from collections.abc import Callable +from gettext import gettext as _ +from typing import ClassVar + +from gi.repository import Adw + +from ..shared.pref_rows.length_spin_row import LengthSpinRow + +logger = logging.getLogger(__name__) + +DEFAULT_TOLERANCE_MM = 0.1 + + +class PathToleranceDialog(Adw.AlertDialog): + """ + Asks for the tolerance of a path clean-up operation and runs it on + confirmation. The last value used per operation is remembered for + the rest of the session. + """ + + _last_values: ClassVar[dict[str, float]] = {} + + def __init__( + self, + heading: str, + body: str, + apply_label: str, + key: str, + on_apply: Callable[[float], None], + ): + super().__init__() + self._key = key + self._on_apply = on_apply + + self.set_heading(heading) + self.set_body(body) + self.add_response("cancel", _("Cancel")) + self.add_response("apply", apply_label) + self.set_response_appearance("apply", Adw.ResponseAppearance.SUGGESTED) + self.set_default_response("apply") + self.set_close_response("cancel") + + self.tolerance_row = LengthSpinRow( + _("Tolerance"), + lower=0.001, + upper=10.0, + step_increment=0.01, + digits=3, + value_in_base=self._last_values.get(key, DEFAULT_TOLERANCE_MM), + ) + group = Adw.PreferencesGroup() + group.add(self.tolerance_row) + self.set_extra_child(group) + + self.connect("response", self._on_response) + + def get_tolerance_mm(self) -> float: + return self.tolerance_row.get_value_in_base_units() + + def _on_response(self, _dialog, response: str): + if response != "apply": + return + tolerance = self.get_tolerance_mm() + self._last_values[self._key] = tolerance + self._on_apply(tolerance) diff --git a/rayforge/ui_gtk/main_menu.py b/rayforge/ui_gtk/main_menu.py index 2c1f4cfe2..4c9b859d1 100644 --- a/rayforge/ui_gtk/main_menu.py +++ b/rayforge/ui_gtk/main_menu.py @@ -108,9 +108,16 @@ def __init__(self): other_group = Gio.Menu() other_group.append(_("Split"), "win.split") + other_group.append(_("Break Apart"), "win.break-apart") other_group.append(_("Export Object..."), "win.export-object") object_menu.append_section(None, other_group) + cleanup_group = Gio.Menu() + cleanup_group.append(_("Close Paths…"), "win.close-paths") + cleanup_group.append(_("Join Open Paths…"), "win.join-paths") + cleanup_group.append(_("Delete Duplicates"), "win.delete-duplicates") + object_menu.append_section(None, cleanup_group) + # Addon section for Object menu self._addon_sections["object"] = Gio.Menu() object_menu.append_section(None, self._addon_sections["object"]) diff --git a/tests/ui_gtk/doceditor/test_path_tolerance_dialog.py b/tests/ui_gtk/doceditor/test_path_tolerance_dialog.py new file mode 100644 index 000000000..04d145c6a --- /dev/null +++ b/tests/ui_gtk/doceditor/test_path_tolerance_dialog.py @@ -0,0 +1,60 @@ +# flake8: noqa: E402 +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") + +import pytest + +from rayforge.ui_gtk.doceditor.path_tolerance_dialog import ( + DEFAULT_TOLERANCE_MM, + PathToleranceDialog, +) + + +@pytest.fixture(autouse=True) +def _reset_remembered_values(ui_context_initializer): + PathToleranceDialog._last_values.clear() + yield + PathToleranceDialog._last_values.clear() + + +def _dialog(calls: list[float], key="close-paths") -> PathToleranceDialog: + return PathToleranceDialog( + heading="Close Paths", + body="Body", + apply_label="Close", + key=key, + on_apply=calls.append, + ) + + +@pytest.mark.ui +def test_apply_runs_callback_with_tolerance(): + calls: list[float] = [] + dialog = _dialog(calls) + assert dialog.get_tolerance_mm() == pytest.approx(DEFAULT_TOLERANCE_MM) + + dialog.tolerance_row.set_value_in_base_units(0.25) + dialog.emit("response", "apply") + + assert calls == [pytest.approx(0.25)] + + +@pytest.mark.ui +def test_cancel_does_not_run_callback(): + calls: list[float] = [] + dialog = _dialog(calls) + dialog.emit("response", "cancel") + assert calls == [] + + +@pytest.mark.ui +def test_last_value_is_remembered_per_operation(): + first = _dialog([]) + first.tolerance_row.set_value_in_base_units(0.5) + first.emit("response", "apply") + + assert _dialog([]).get_tolerance_mm() == pytest.approx(0.5) + other = _dialog([], key="join-paths") + assert other.get_tolerance_mm() == pytest.approx(DEFAULT_TOLERANCE_MM) diff --git a/website/docs/features/path-cleanup.md b/website/docs/features/path-cleanup.md new file mode 100644 index 000000000..2286ff963 --- /dev/null +++ b/website/docs/features/path-cleanup.md @@ -0,0 +1,53 @@ +--- +description: + "Clean up imported vector paths in Rayforge: close nearly closed paths, join open pieces, delete + duplicate shapes and break a workpiece apart into its contours." +--- + +# Path Clean-Up + +Logos and drawings from other programs are not always clean. An outline may stop a fraction of a +millimetre before its start point, a shape may be made of several open pieces, or the same path may +be in the file twice. Rayforge cuts such paths exactly as they are, so a shape that only looks +closed is treated as open, and a doubled path is cut twice. + +The path clean-up tools fix this on the selected workpieces. You find them in the **Object** menu +and under **Clean Up Paths** in the canvas context menu. Each tool is a single undo step. + +## Close Paths + +**Close Paths…** closes every open path whose start and end point are closer than the tolerance you +enter. The gap is bridged with a straight line between the two existing points, so the shape does +not move or change size. + +## Join Open Paths + +**Join Open Paths…** connects open paths whose end points are closer than the tolerance into longer +paths. Pieces are reversed where needed, so the order and direction in which they were drawn do not +matter. When the ends of a joined path then meet, the path is closed as well. + +Both tools remember the last tolerance you used during the session. A tolerance between 0.05 mm and +0.2 mm works for most imported files. + +## Delete Duplicates + +**Delete Duplicates** removes paths that lie exactly on top of another path in the same workpiece, +regardless of their direction or start point. When you select several workpieces, a workpiece that +is an exact copy of another selected workpiece at the same position is removed too. + +Overlapping paths that only share part of their length are left alone. The +[Merge Lines](merge-lines) post-processor takes care of those when the job is generated. + +## Break Apart + +**Break Apart** turns every path of a workpiece into a workpiece of its own, holes and open paths +included. This differs from **Split**, which keeps an island together with its holes. + +Path clean-up only works on imported and traced workpieces. Workpieces that come from a sketch are +edited in the [Sketcher](sketcher/index) instead. + +## Related Pages + +- [Merge Lines](merge-lines) - Cutting overlapping segments only once +- [Canvas Tools](../ui/canvas-tools) - Selecting and deleting individual segments +- [Importing Files](../files/importing) - Bringing designs into Rayforge diff --git a/website/docs/ui/canvas-tools.md b/website/docs/ui/canvas-tools.md index 81db70c84..73bf633c3 100644 --- a/website/docs/ui/canvas-tools.md +++ b/website/docs/ui/canvas-tools.md @@ -107,6 +107,9 @@ and delete individual vector segments of the workpiece. To exit vector editing mode, click outside the workpiece or press Escape. +To close gaps, join open pieces, delete duplicate paths or break a workpiece apart, use the +[Path Clean-Up](../features/path-cleanup) tools. + --- **Next**: [3D View →](3d-preview) diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/features/path-cleanup.md b/website/i18n/de/docusaurus-plugin-content-docs/current/features/path-cleanup.md new file mode 100644 index 000000000..8bfeff794 --- /dev/null +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/features/path-cleanup.md @@ -0,0 +1,51 @@ +# Pfade bereinigen + +Logos und Zeichnungen aus anderen Programmen sind nicht immer sauber. Ein Umriss kann einen +Bruchteil eines Millimeters vor seinem Startpunkt enden, eine Form kann aus mehreren offenen Teilen +bestehen, oder derselbe Pfad steht zweimal in der Datei. Rayforge schneidet solche Pfade genau so, +wie sie sind: Eine Form, die nur geschlossen aussieht, wird als offen behandelt, und ein doppelter +Pfad wird zweimal geschnitten. + +Die Werkzeuge zum Bereinigen von Pfaden beheben das bei den ausgewählten Werkstücken. Du findest sie +im Menü **Objekt** und im Kontextmenü der Arbeitsfläche unter **Pfade bereinigen**. Jedes Werkzeug +ist ein einzelner Rückgängig-Schritt. + +## Pfade schließen + +**Pfade schließen…** schließt jeden offenen Pfad, dessen Anfangs- und Endpunkt näher beieinander +liegen als die eingegebene Toleranz. Die Lücke wird mit einer geraden Linie zwischen den beiden +vorhandenen Punkten überbrückt, sodass sich die Form weder verschiebt noch ihre Größe ändert. + +## Offene Pfade verbinden + +**Offene Pfade verbinden…** verbindet offene Pfade, deren Endpunkte näher beieinander liegen als die +Toleranz, zu längeren Pfaden. Teile werden bei Bedarf umgedreht, die Reihenfolge und Richtung, in +der sie gezeichnet wurden, spielt also keine Rolle. Treffen sich danach die Enden eines verbundenen +Pfads, wird er außerdem geschlossen. + +Beide Werkzeuge merken sich die zuletzt verwendete Toleranz für die laufende Sitzung. Für die +meisten importierten Dateien passt eine Toleranz zwischen 0,05 mm und 0,2 mm. + +## Duplikate löschen + +**Duplikate löschen** entfernt Pfade, die genau auf einem anderen Pfad im selben Werkstück liegen, +unabhängig von ihrer Richtung oder ihrem Startpunkt. Wenn du mehrere Werkstücke auswählst, wird auch +ein Werkstück entfernt, das eine exakte Kopie eines anderen ausgewählten Werkstücks an derselben +Position ist. + +Überlappende Pfade, die nur einen Teil ihrer Länge gemeinsam haben, bleiben erhalten. Um diese +kümmert sich der Postprozessor [Linien zusammenführen](merge-lines), wenn der Auftrag erzeugt wird. + +## Zerlegen + +**Zerlegen** macht aus jedem Pfad eines Werkstücks ein eigenes Werkstück, einschließlich Löchern und +offener Pfade. Das unterscheidet sich von **Teilen**, das eine Insel mit ihren Löchern zusammenhält. + +Das Bereinigen von Pfaden funktioniert nur bei importierten und nachgezeichneten Werkstücken. +Werkstücke aus einer Skizze bearbeitest du stattdessen im [Sketcher](sketcher/index). + +## Verwandte Seiten + +- [Linien zusammenführen](merge-lines) - Überlappende Segmente nur einmal schneiden +- [Canvas-Werkzeuge](../ui/canvas-tools) - Einzelne Segmente auswählen und löschen +- [Dateien importieren](../files/importing) - Designs in Rayforge laden diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/ui/canvas-tools.md b/website/i18n/de/docusaurus-plugin-content-docs/current/ui/canvas-tools.md index b8f7c4fe3..4f725fa94 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/ui/canvas-tools.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/ui/canvas-tools.md @@ -166,6 +166,9 @@ In diesem Modus kannst du einzelne Vektorsegmente des Werkstücks auswählen und Um den Vektorbearbeitungsmodus zu verlassen, klicke außerhalb des Werkstücks oder drücke Escape. +Um Lücken zu schließen, offene Teile zu verbinden, doppelte Pfade zu löschen oder ein Werkstück zu +zerlegen, verwende die Werkzeuge unter [Pfade bereinigen](../features/path-cleanup). + --- **Weiter**: [3D-Vorschau →](3d-preview) diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/features/path-cleanup.md b/website/i18n/es/docusaurus-plugin-content-docs/current/features/path-cleanup.md new file mode 100644 index 000000000..5dada85f2 --- /dev/null +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/features/path-cleanup.md @@ -0,0 +1,50 @@ +# Limpieza de rutas + +Los logotipos y dibujos de otros programas no siempre están limpios. Un contorno puede terminar una +fracción de milímetro antes de su punto inicial, una forma puede estar hecha de varias piezas +abiertas o la misma ruta puede estar dos veces en el archivo. Rayforge corta esas rutas tal como +son: una forma que solo parece cerrada se trata como abierta y una ruta duplicada se corta dos +veces. + +Las herramientas de limpieza de rutas corrigen esto en las piezas de trabajo seleccionadas. Las +encontrarás en el menú **Objeto** y en **Limpiar rutas** del menú contextual del lienzo. Cada +herramienta es un solo paso de deshacer. + +## Cerrar rutas + +**Cerrar rutas…** cierra cada ruta abierta cuyo punto inicial y final están más cerca que la +tolerancia que indiques. El hueco se salva con una línea recta entre los dos puntos existentes, así +que la forma no se mueve ni cambia de tamaño. + +## Unir rutas abiertas + +**Unir rutas abiertas…** une en rutas más largas las rutas abiertas cuyos extremos están más cerca +que la tolerancia. Las piezas se invierten cuando hace falta, así que el orden y la dirección en que +se dibujaron no importan. Si después los extremos de una ruta unida coinciden, la ruta también se +cierra. + +Ambas herramientas recuerdan la última tolerancia que usaste durante la sesión. Una tolerancia entre +0,05 mm y 0,2 mm funciona para la mayoría de los archivos importados. + +## Eliminar duplicados + +**Eliminar duplicados** quita las rutas que están exactamente encima de otra ruta de la misma pieza +de trabajo, sin importar su dirección o punto inicial. Si seleccionas varias piezas de trabajo, +también se quita una pieza que sea una copia exacta de otra pieza seleccionada en la misma posición. + +Las rutas que solo se superponen en parte de su longitud se mantienen. De ellas se encarga el +posprocesador [Fusionar líneas](merge-lines) al generar el trabajo. + +## Separar + +**Separar** convierte cada ruta de una pieza de trabajo en una pieza propia, incluidos los agujeros +y las rutas abiertas. Esto es distinto de **Dividir**, que mantiene una isla junto con sus agujeros. + +La limpieza de rutas solo funciona en piezas de trabajo importadas y trazadas. Las piezas que vienen +de un boceto se editan en el [Sketcher](sketcher/index). + +## Páginas relacionadas + +- [Fusionar líneas](merge-lines) - Cortar los segmentos superpuestos una sola vez +- [Herramientas del lienzo](../ui/canvas-tools) - Seleccionar y eliminar segmentos individuales +- [Importar archivos](../files/importing) - Llevar diseños a Rayforge diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/ui/canvas-tools.md b/website/i18n/es/docusaurus-plugin-content-docs/current/ui/canvas-tools.md index 5e3bf3edc..45b9a3a55 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/ui/canvas-tools.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/ui/canvas-tools.md @@ -166,6 +166,9 @@ este modo, puedes seleccionar y eliminar segmentos de vector individuales de la Para salir del modo de edición de vectores, haz clic fuera de la pieza de trabajo o presiona Escape. +Para cerrar huecos, unir piezas abiertas, eliminar rutas duplicadas o separar una pieza de trabajo, +usa las herramientas de [Limpieza de rutas](../features/path-cleanup). + --- **Siguiente**: [Vista Previa 3D →](3d-preview) diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/path-cleanup.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/path-cleanup.md new file mode 100644 index 000000000..4ba9f6cd9 --- /dev/null +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/path-cleanup.md @@ -0,0 +1,51 @@ +# Nettoyage des chemins + +Les logos et dessins provenant d'autres logiciels ne sont pas toujours propres. Un contour peut +s'arrêter une fraction de millimètre avant son point de départ, une forme peut être composée de +plusieurs morceaux ouverts, ou le même chemin peut figurer deux fois dans le fichier. Rayforge +découpe ces chemins tels quels : une forme qui semble seulement fermée est traitée comme ouverte, et +un chemin en double est découpé deux fois. + +Les outils de nettoyage des chemins corrigent cela sur les pièces sélectionnées. Vous les trouverez +dans le menu **Objet** et sous **Nettoyer les chemins** dans le menu contextuel du canevas. Chaque +outil correspond à une seule étape d'annulation. + +## Fermer les chemins + +**Fermer les chemins…** ferme chaque chemin ouvert dont le point de départ et le point d'arrivée +sont plus proches que la tolérance saisie. L'écart est comblé par une ligne droite entre les deux +points existants : la forme ne se déplace pas et ne change pas de taille. + +## Joindre les chemins ouverts + +**Joindre les chemins ouverts…** relie en chemins plus longs les chemins ouverts dont les extrémités +sont plus proches que la tolérance. Les morceaux sont inversés si nécessaire, l'ordre et le sens +dans lesquels ils ont été dessinés n'ont donc pas d'importance. Si les extrémités d'un chemin joint +se rejoignent ensuite, le chemin est également fermé. + +Les deux outils retiennent la dernière tolérance utilisée pendant la session. Une tolérance comprise +entre 0,05 mm et 0,2 mm convient à la plupart des fichiers importés. + +## Supprimer les doublons + +**Supprimer les doublons** retire les chemins qui se trouvent exactement sur un autre chemin de la +même pièce, quels que soient leur sens ou leur point de départ. Si vous sélectionnez plusieurs +pièces, une pièce qui est la copie exacte d'une autre pièce sélectionnée à la même position est +également retirée. + +Les chemins qui ne se chevauchent que sur une partie de leur longueur sont conservés. Le +post-processeur [Fusionner les lignes](merge-lines) s'en charge lors de la génération de la tâche. + +## Décomposer + +**Décomposer** transforme chaque chemin d'une pièce en une pièce distincte, trous et chemins ouverts +compris. C'est différent de **Scinder**, qui garde un îlot avec ses trous. + +Le nettoyage des chemins ne fonctionne que sur les pièces importées et vectorisées. Les pièces +issues d'une esquisse se modifient dans le [Sketcher](sketcher/index). + +## Pages connexes + +- [Fusionner les lignes](merge-lines) - Ne découper qu'une fois les segments qui se chevauchent +- [Outils du canevas](../ui/canvas-tools) - Sélectionner et supprimer des segments individuels +- [Importer des fichiers](../files/importing) - Importer des dessins dans Rayforge diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/ui/canvas-tools.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/ui/canvas-tools.md index 1d0a10b36..4fc699ba7 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/ui/canvas-tools.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/ui/canvas-tools.md @@ -168,6 +168,9 @@ vous pouvez sélectionner et supprimer des segments vectoriels individuels de la Pour quitter le mode d'édition vectorielle, cliquez en dehors de la pièce ou appuyez sur Échap. +Pour fermer des écarts, joindre des morceaux ouverts, supprimer des chemins en double ou décomposer +une pièce, utilisez les outils de [Nettoyage des chemins](../features/path-cleanup). + --- **Suivant** : [Prévisualisation 3D →](3d-preview) diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/path-cleanup.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/path-cleanup.md new file mode 100644 index 000000000..dae47055a --- /dev/null +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/path-cleanup.md @@ -0,0 +1,47 @@ +# पथ सफ़ाई + +दूसरे प्रोग्राम से आए लोगो और ड्रॉइंग हमेशा साफ़ नहीं होते. कोई आउटलाइन अपने आरंभ बिंदु से एक +मिलीमीटर के अंश पहले रुक सकती है, कोई आकृति कई खुले टुकड़ों से बनी हो सकती है, या एक ही पथ फ़ाइल में +दो बार हो सकता है. Rayforge ऐसे पथों को ठीक वैसे ही काटता है जैसे वे हैं: जो आकृति केवल बंद दिखती है +उसे खुला माना जाता है, और दोहरा पथ दो बार काटा जाता है. + +पथ सफ़ाई टूल चुने गए वर्कपीस पर इसे ठीक करते हैं. ये **ऑब्जेक्ट** मेनू में और कैनवस के संदर्भ मेनू +में **पथ साफ़ करें** के अंतर्गत मिलते हैं. हर टूल एक ही पूर्ववत चरण है. + +## पथ बंद करें + +**पथ बंद करें…** हर उस खुले पथ को बंद करता है जिसके आरंभ और अंत बिंदु आपकी दी गई सहनशीलता से अधिक +निकट हैं. अंतराल को दोनों मौजूदा बिंदुओं के बीच एक सीधी रेखा से जोड़ा जाता है, इसलिए आकृति न हिलती +है और न उसका आकार बदलता है. + +## खुले पथ जोड़ें + +**खुले पथ जोड़ें…** उन खुले पथों को लंबे पथों में जोड़ता है जिनके अंत बिंदु सहनशीलता से अधिक निकट +हैं. ज़रूरत होने पर टुकड़ों की दिशा उलट दी जाती है, इसलिए यह मायने नहीं रखता कि उन्हें किस क्रम और +दिशा में बनाया गया था. अगर जुड़े हुए पथ के सिरे फिर मिल जाते हैं, तो पथ बंद भी कर दिया जाता है. + +दोनों टूल सत्र के दौरान आपकी पिछली सहनशीलता याद रखते हैं. ज़्यादातर आयात की गई फ़ाइलों के लिए 0.05 +mm से 0.2 mm के बीच की सहनशीलता ठीक रहती है. + +## डुप्लिकेट हटाएँ + +**डुप्लिकेट हटाएँ** उन पथों को हटाता है जो उसी वर्कपीस के किसी दूसरे पथ के ठीक ऊपर हैं, चाहे उनकी +दिशा या आरंभ बिंदु कुछ भी हो. अगर आप कई वर्कपीस चुनते हैं, तो वह वर्कपीस भी हटा दिया जाता है जो उसी +स्थान पर किसी दूसरे चुने गए वर्कपीस की हूबहू प्रति है. + +जो पथ अपनी लंबाई के केवल एक हिस्से में ओवरलैप करते हैं, वे बने रहते हैं. जॉब बनाते समय +[रेखाएँ मर्ज करें](merge-lines) पोस्ट-प्रोसेसर उन्हें संभालता है. + +## अलग-अलग करें + +**अलग-अलग करें** किसी वर्कपीस के हर पथ को उसका अपना वर्कपीस बना देता है, छेद और खुले पथ भी. यह +**विभाजित करें** से अलग है, जो किसी द्वीप को उसके छेदों के साथ एक रखता है. + +पथ सफ़ाई केवल आयात किए गए और ट्रेस किए गए वर्कपीस पर काम करती है. स्केच से बने वर्कपीस को +[स्केचर](sketcher/index) में संपादित करें. + +## संबंधित पृष्ठ + +- [रेखाएँ मर्ज करें](merge-lines) - ओवरलैप होने वाले खंडों को केवल एक बार काटना +- [कैनवस उपकरण](../ui/canvas-tools) - अलग-अलग खंड चुनना और हटाना +- [फ़ाइलें आयात करना](../files/importing) - Rayforge में डिज़ाइन लाना diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/ui/canvas-tools.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/ui/canvas-tools.md index f29e4baf2..b9521d394 100644 --- a/website/i18n/hi/docusaurus-plugin-content-docs/current/ui/canvas-tools.md +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/ui/canvas-tools.md @@ -108,6 +108,9 @@ description: वेक्टर संपादन मोड से बाहर निकलने के लिए, वर्कपीस के बाहर क्लिक करें या Escape दबाएँ. +अंतराल बंद करने, खुले टुकड़ों को जोड़ने, डुप्लिकेट पथ हटाने या किसी वर्कपीस को अलग-अलग करने के लिए +[पथ सफ़ाई](../features/path-cleanup) टूल का उपयोग करें. + --- **अगला:** [3D दृश्य →](3d-preview) diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/path-cleanup.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/path-cleanup.md new file mode 100644 index 000000000..22771fbcb --- /dev/null +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/path-cleanup.md @@ -0,0 +1,51 @@ +# Limpeza de caminhos + +Logotipos e desenhos vindos de outros programas nem sempre estão limpos. Um contorno pode terminar +uma fração de milímetro antes do ponto inicial, uma forma pode ser composta de várias partes +abertas, ou o mesmo caminho pode aparecer duas vezes no arquivo. O Rayforge corta esses caminhos +exatamente como estão: uma forma que só parece fechada é tratada como aberta, e um caminho duplicado +é cortado duas vezes. + +As ferramentas de limpeza de caminhos corrigem isso nas peças de trabalho selecionadas. Você as +encontra no menu **Objeto** e em **Limpar caminhos** no menu de contexto da tela. Cada ferramenta é +um único passo de desfazer. + +## Fechar caminhos + +**Fechar caminhos…** fecha cada caminho aberto cujos pontos inicial e final estão mais próximos do +que a tolerância informada. A lacuna é preenchida com uma linha reta entre os dois pontos +existentes, então a forma não se move nem muda de tamanho. + +## Unir caminhos abertos + +**Unir caminhos abertos…** junta em caminhos mais longos os caminhos abertos cujas extremidades +estão mais próximas do que a tolerância. As partes são invertidas quando necessário, então a ordem e +o sentido em que foram desenhadas não importam. Quando as extremidades de um caminho unido passam a +se encontrar, o caminho também é fechado. + +As duas ferramentas lembram a última tolerância usada durante a sessão. Uma tolerância entre 0,05 mm +e 0,2 mm funciona para a maioria dos arquivos importados. + +## Excluir duplicados + +**Excluir duplicados** remove caminhos que estão exatamente sobre outro caminho da mesma peça de +trabalho, independentemente do sentido ou do ponto inicial. Se você selecionar várias peças de +trabalho, uma peça que seja cópia exata de outra peça selecionada na mesma posição também é +removida. + +Caminhos que se sobrepõem apenas em parte do comprimento são mantidos. O pós-processador +[Mesclar linhas](merge-lines) cuida deles quando o trabalho é gerado. + +## Separar + +**Separar** transforma cada caminho de uma peça de trabalho em uma peça própria, incluindo furos e +caminhos abertos. Isso é diferente de **Dividir**, que mantém uma ilha junto com seus furos. + +A limpeza de caminhos só funciona em peças de trabalho importadas e vetorizadas. Peças que vêm de um +esboço são editadas no [Sketcher](sketcher/index). + +## Páginas relacionadas + +- [Mesclar linhas](merge-lines) - Cortar segmentos sobrepostos apenas uma vez +- [Ferramentas de tela](../ui/canvas-tools) - Selecionar e excluir segmentos individuais +- [Importando arquivos](../files/importing) - Trazer desenhos para o Rayforge diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/ui/canvas-tools.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/ui/canvas-tools.md index ba7d15ed7..db63200b1 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/ui/canvas-tools.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/ui/canvas-tools.md @@ -166,6 +166,9 @@ modo, você pode selecionar e excluir segmentos de vetor individuais da peça de Para sair do modo de edição de vetores, clique fora da peça de trabalho ou pressione Escape. +Para fechar lacunas, unir partes abertas, excluir caminhos duplicados ou separar uma peça de +trabalho, use as ferramentas de [Limpeza de caminhos](../features/path-cleanup). + --- **Próximo**: [Visualização 3D →](3d-preview) diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/path-cleanup.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/path-cleanup.md new file mode 100644 index 000000000..087cd40a1 --- /dev/null +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/path-cleanup.md @@ -0,0 +1,48 @@ +# Очищення шляхів + +Логотипи й креслення з інших програм не завжди чисті. Контур може закінчуватися на частку міліметра +раніше за свою початкову точку, фігура може складатися з кількох відкритих частин, або той самий +шлях може бути у файлі двічі. Rayforge ріже такі шляхи саме такими, якими вони є: фігура, яка лише +здається замкненою, вважається відкритою, а подвоєний шлях ріжеться двічі. + +Інструменти очищення шляхів виправляють це для вибраних робочих деталей. Вони є в меню **Об'єкт** і +в контекстному меню полотна в розділі **Очистити шляхи**. Кожен інструмент — це один крок +скасування. + +## Замкнути шляхи + +**Замкнути шляхи…** замикає кожен відкритий шлях, у якого початкова й кінцева точки ближчі за +введений допуск. Проміжок з'єднується прямою лінією між двома наявними точками, тож фігура не +зсувається і не змінює розміру. + +## З'єднати відкриті шляхи + +**З'єднати відкриті шляхи…** об'єднує в довші шляхи відкриті шляхи, кінцеві точки яких ближчі за +допуск. За потреби частини розвертаються, тож порядок і напрямок, у якому їх намалювали, не мають +значення. Якщо після цього кінці з'єднаного шляху збігаються, шлях також замикається. + +Обидва інструменти пам'ятають останній використаний допуск до кінця сеансу. Для більшості +імпортованих файлів підходить допуск від 0,05 мм до 0,2 мм. + +## Видалити дублікати + +**Видалити дублікати** прибирає шляхи, що лежать точно на іншому шляху тієї самої робочої деталі, +незалежно від їхнього напрямку чи початкової точки. Якщо вибрати кілька робочих деталей, то деталь, +яка є точною копією іншої вибраної деталі в тому самому місці, також видаляється. + +Шляхи, що перекриваються лише частиною своєї довжини, залишаються. Ними опікується постпроцесор +[Об'єднання ліній](merge-lines) під час створення завдання. + +## Розібрати на частини + +**Розібрати на частини** робить із кожного шляху робочої деталі окрему деталь, включно з отворами й +відкритими шляхами. Це відрізняється від **Розділити**, яке тримає острів разом з його отворами. + +Очищення шляхів працює лише з імпортованими та трасованими робочими деталями. Деталі, створені зі +скетчу, редагуйте у [Sketcher](sketcher/index). + +## Пов'язані сторінки + +- [Об'єднання ліній](merge-lines) - Різати сегменти, що перекриваються, лише один раз +- [Інструменти полотна](../ui/canvas-tools) - Вибір і видалення окремих сегментів +- [Імпортування файлів](../files/importing) - Завантаження дизайнів у Rayforge diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/ui/canvas-tools.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/ui/canvas-tools.md index 1dcf8c81b..83cae063b 100644 --- a/website/i18n/uk/docusaurus-plugin-content-docs/current/ui/canvas-tools.md +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/ui/canvas-tools.md @@ -103,6 +103,9 @@ Щоб вийти з режиму редагування векторів, клацніть поза робочою деталлю або натисніть Escape. +Щоб закрити проміжки, з'єднати відкриті частини, видалити дублікати шляхів або розібрати робочу +деталь на частини, скористайтеся інструментами [Очищення шляхів](../features/path-cleanup). + --- **Далі**: [3D вид →](3d-preview) diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/path-cleanup.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/path-cleanup.md new file mode 100644 index 000000000..604717dfc --- /dev/null +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/path-cleanup.md @@ -0,0 +1,34 @@ +# 清理路径 + +来自其他软件的标志和图纸并不总是干净的。轮廓可能在距起点不到一毫米的地方就停止了,一个形状可能由多段开放的片段组成,或者同一条路径在文件中出现了两次。Rayforge 会按原样切割这些路径:看起来闭合但实际未闭合的形状会被当作开放路径处理,重复的路径会被切割两次。 + +路径清理工具可以修复所选工件中的这些问题。您可以在**对象**菜单中,以及画布右键菜单的**清理路径**下找到它们。每个工具都是一个撤销步骤。 + +## 闭合路径 + +**闭合路径…**会闭合所有起点与终点距离小于您输入的公差的开放路径。缺口由两个现有点之间的一条直线连接,因此形状不会移动,也不会改变大小。 + +## 连接开放路径 + +**连接开放路径…**会把端点距离小于公差的开放路径连接成更长的路径。必要时会反转片段方向,因此片段的绘制顺序和方向都无关紧要。如果连接后的路径首尾相接,该路径也会被闭合。 + +两个工具都会在本次会话中记住您上次使用的公差。对大多数导入的文件来说,0.05 mm 到 0.2 +mm 之间的公差即可。 + +## 删除重复项 + +**删除重复项**会删除与同一工件中另一条路径完全重合的路径,无论其方向或起点如何。如果您选择了多个工件,与另一个所选工件位置相同且完全相同的工件也会被删除。 + +只有部分长度重叠的路径会被保留。生成作业时,[合并线条](merge-lines)后处理器会处理这些路径。 + +## 打散 + +**打散**会把工件的每条路径都变成一个独立的工件,包括孔和开放路径。这与**分割**不同,分割会把一个岛与它的孔保留在一起。 + +路径清理只适用于导入和描摹得到的工件。来自草图的工件请在[草图编辑器](sketcher/index)中编辑。 + +## 相关页面 + +- [合并线条](merge-lines) - 重叠的线段只切割一次 +- [画布工具](../ui/canvas-tools) - 选择和删除单个线段 +- [导入文件](../files/importing) - 将设计导入 Rayforge diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/ui/canvas-tools.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/ui/canvas-tools.md index f9fdc7b05..efa60de09 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/ui/canvas-tools.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/ui/canvas-tools.md @@ -159,6 +159,8 @@ 要退出矢量编辑模式,请点击工件外部或按 Escape。 +要闭合缺口、连接开放的片段、删除重复路径或将工件打散,请使用[清理路径](../features/path-cleanup)工具。 + --- **下一步:**[3D 预览 →](3d-preview) diff --git a/website/sidebars.js b/website/sidebars.js index c1577ae78..a293e104e 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -102,6 +102,7 @@ module.exports = { ], }, 'features/arrays', + 'features/path-cleanup', 'features/multi-layer', 'features/stock-handling', 'features/framing-your-job', From 550e176030e2a1fb2176cacb3be866a9d657e9a0 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Fri, 9 Oct 2026 13:12:57 +0200 Subject: [PATCH 028/104] perf(editor): index contours spatially when deleting duplicates Comparing every pair of contours with a similar length made Delete Duplicates take seconds on files with thousands of short, equally long segments (a 3,600-segment line soup took 9 s). Kept contours are now indexed by the lower-left corner of their bounding box, so only neighbouring candidates are compared (0.03 s for the same file). Refs #516 --- rayforge/core/path_cleanup.py | 57 ++++++++++++++++++++++++--------- tests/core/test_path_cleanup.py | 14 ++++++++ 2 files changed, 55 insertions(+), 16 deletions(-) diff --git a/rayforge/core/path_cleanup.py b/rayforge/core/path_cleanup.py index 8a1c3cbcf..3a41b73d0 100644 --- a/rayforge/core/path_cleanup.py +++ b/rayforge/core/path_cleanup.py @@ -249,6 +249,30 @@ def _length_slack(tolerance: float) -> float: return 4.0 * tolerance + 1e-9 +class _CornerIndex: + """ + A spatial hash of the lower-left bounding box corner of kept + contours. Duplicates have corners within the tolerance, so only + neighbouring cells need to be compared. + """ + + def __init__(self, tolerance: float): + self._cell = max(tolerance, 1e-9) + self._cells: dict[tuple[int, int], list[int]] = {} + + def _key(self, x: float, y: float) -> tuple[int, int]: + return math.floor(x / self._cell), math.floor(y / self._cell) + + def add(self, index: int, rect: tuple[float, float, float, float]): + self._cells.setdefault(self._key(rect[0], rect[1]), []).append(index) + + def near(self, rect: tuple[float, float, float, float]) -> Iterator[int]: + kx, ky = self._key(rect[0], rect[1]) + for dx in (-1, 0, 1): + for dy in (-1, 0, 1): + yield from self._cells.get((kx + dx, ky + dy), ()) + + def remove_duplicate_contours( geo: Geometry, tolerance: float ) -> tuple[Geometry, int]: @@ -266,24 +290,25 @@ def remove_duplicate_contours( A tuple of the new geometry and the number of removed contours. """ contours = _drawn_contours(geo) - lengths = [c.distance() for c in contours] - order = sorted(range(len(contours)), key=lambda i: lengths[i]) - duplicates: set[int] = set() slack = _length_slack(tolerance) - for pos, i in enumerate(order): - if i in duplicates: + index = _CornerIndex(tolerance) + lengths: list[float] = [] + kept: list[Geometry] = [] + removed = 0 + for contour in contours: + rect = contour.rect() + length = contour.distance() + if any( + abs(lengths[k] - length) <= slack + and _contours_match(kept[k], contour, tolerance) + for k in index.near(rect) + ): + removed += 1 continue - for j in order[pos + 1 :]: - if lengths[j] - lengths[i] > slack: - break - if j in duplicates: - continue - if _contours_match(contours[i], contours[j], tolerance): - duplicates.add(max(i, j)) - if max(i, j) == i: - break - kept = [c for i, c in enumerate(contours) if i not in duplicates] - return _concat(kept), len(duplicates) + index.add(len(kept), rect) + lengths.append(length) + kept.append(contour) + return _concat(kept), removed def geometries_match(a: Geometry, b: Geometry, tolerance: float) -> bool: diff --git a/tests/core/test_path_cleanup.py b/tests/core/test_path_cleanup.py index 8169a244e..9f01a9699 100644 --- a/tests/core/test_path_cleanup.py +++ b/tests/core/test_path_cleanup.py @@ -271,6 +271,20 @@ def test_removes_triple_copies(self): _result, removed = remove_duplicate_contours(geo, 0.01) assert removed == 2 + def test_many_scattered_duplicates(self): + pieces = [] + for i in range(200): + x, y = (i * 37) % 290, (i * 53) % 290 + pieces.append(_polyline([(x, y), (x + 5, y)])) + copies = [ + _polyline(list(reversed([(x, y), (x + 5, y)]))) + for x, y in (((i * 37) % 290, (i * 53) % 290) for i in range(200)) + ] + geo = _concat(*pieces, *copies) + result, removed = remove_duplicate_contours(geo, 0.01) + assert removed == 200 + assert len(_contours(result)) == 200 + class TestGeometriesMatch: def test_same_shapes_in_other_order_match(self): From 013e9857d52e6e5201c72423d279bde5bbda696b Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Fri, 9 Oct 2026 13:03:02 +0200 Subject: [PATCH 029/104] fix(pipeline): pass the step's bidirectional scan offset to its transformer Since the pipeline moved to raygeo intents, _transformer_settings() only carried machine-wide values, so BidirScanOffsetTransformer always saw an offset of 0 and the Engrave step's Bidirectional Scan Offset had no effect. Build the transformer settings per step and add the step's cut speed and bidir_x_offset_mm. Fold both into the workpiece compute token, as the offset is applied inside the compute stage and a changed value must not be served from the cache. Refs #307 --- CHANGELOG.md | 4 ++ rayforge/pipeline/intent_builder.py | 60 +++++++++++++++++++-------- tests/pipeline/test_intent_builder.py | 29 +++++++++++++ 3 files changed, 75 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 283c89fe6..c0c83a0e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Engrave: the step's Bidirectional Scan Offset had no effect since + the pipeline moved to raygeo intents, because the value no longer + reached the transformer; changing it now also refreshes the cached + toolpath (#307) - Ruida: the setup wizard no longer requires the optional USB device and Magic fields, so Next is enabled again once a valid hostname is entered; the Magic field is prefilled with the standard 0x88 diff --git a/rayforge/pipeline/intent_builder.py b/rayforge/pipeline/intent_builder.py index f8a540598..e0f97eeb8 100644 --- a/rayforge/pipeline/intent_builder.py +++ b/rayforge/pipeline/intent_builder.py @@ -772,6 +772,7 @@ def _compute_token( ), "assembler_params": _canonical(self._assembler_params(step, wp)), "wpxf": _canonical(step.per_workpiece_transformers_dicts), + "bidir_offset": self._bidir_offset_params(step), } if pos_sensitive: payload["xf_rev"] = wp.transform_revision @@ -897,6 +898,7 @@ def _wp_stage(self, step: Step, wp: WorkPiece) -> StageSpec.Compute: payload.transformers = self._build_transformer_specs( step.per_workpiece_transformers_dicts, workpiece=wp, + step=step, ) if step.uses_global_state and step.layer: @@ -940,6 +942,7 @@ def _build_transformer_specs( transformer_dicts: list[dict[str, Any]], *, workpiece: WorkPiece | None = None, + step: Step | None = None, ) -> list[Any]: """Build typed Rust ``*Spec`` pyclasses from a list of serialised transformer dicts. @@ -948,7 +951,8 @@ def _build_transformer_specs( calls ``to_spec`` to produce the typed spec the Rust compute and aggregate stages consume. ``workpiece`` is forwarded so that position-sensitive transformers (e.g. CropTransformer) - can resolve their regions. + can resolve their regions, and ``step`` provides the + step-specific settings (see :meth:`_transformer_settings`). """ transformers: list[OpsTransformer] = [] for t_dict in transformer_dicts: @@ -974,7 +978,7 @@ def _build_transformer_specs( if not transformers: return [] stock = self._resolve_stock_geometries() - settings = self._transformer_settings() + settings = self._transformer_settings(step) specs: list = [] for t in transformers: @@ -983,28 +987,47 @@ def _build_transformer_specs( specs.append(t.to_spec(workpiece, stock, settings)) return specs - def _transformer_settings(self) -> dict[str, Any] | None: + def _bidir_offset_params(self, step: Step) -> list[Any]: + """The inputs of the bidirectional scan offset, which the + compute stage applies through the per-workpiece transformers.""" + return [step.cut_speed, getattr(step, "bidir_x_offset_mm", 0.0)] + + def _transformer_settings( + self, step: Step | None = None + ) -> dict[str, Any] | None: """Return the settings dict forwarded to ``to_spec``. Currently this carries the ``driver_native_overscan`` flag so :class:`OverscanTransformer` can short-circuit when the - machine driver handles overscan itself, and the machine + machine driver handles overscan itself, the machine kinematics so acceleration-aware transformers (e.g. the scanline merging inside :class:`Optimize`) can build their - specs from them. + specs from them. With a ``step`` it adds the step's speed and + its own bidirectional scan offset for + :class:`BidirScanOffsetTransformer`. """ - if self._machine is None: - return None - try: - native = bool(self._machine.driver.native_overscan) - except AttributeError: - native = False - return { - "driver_native_overscan": native, - "machine_max_cut_speed": self._machine.max_cut_speed, - "machine_max_travel_speed": self._machine.max_travel_speed, - "machine_acceleration": self._machine.acceleration, - } + settings: dict[str, Any] = {} + if self._machine is not None: + try: + native = bool(self._machine.driver.native_overscan) + except AttributeError: + native = False + settings.update( + { + "driver_native_overscan": native, + "machine_max_cut_speed": self._machine.max_cut_speed, + "machine_max_travel_speed": ( + self._machine.max_travel_speed + ), + "machine_acceleration": self._machine.acceleration, + } + ) + if step is not None: + settings["cut_speed"] = step.cut_speed + settings["bidir_x_offset_mm"] = getattr( + step, "bidir_x_offset_mm", 0.0 + ) + return settings or None def _resolve_stock_geometries(self) -> list[Any] | None: """Return the world-space stock boundary geometries. @@ -1129,7 +1152,8 @@ def _step_stage( wrap_end=[], machine=self._machine_params(), transformers=self._build_transformer_specs( - step.per_step_transformers_dicts + step.per_step_transformers_dicts, + step=step, ), ) return StageSpec.Aggregate(spec=spec) diff --git a/tests/pipeline/test_intent_builder.py b/tests/pipeline/test_intent_builder.py index 5a6c80bee..b0376c569 100644 --- a/tests/pipeline/test_intent_builder.py +++ b/tests/pipeline/test_intent_builder.py @@ -30,6 +30,7 @@ from raygeo.ops.convert import Encoder, GcodeSpec, PythonEncoder from raygeo.ops.material.spec import CylinderStock, MaterialFoldSpec from raygeo.ops.part import Part +from raygeo.ops.transform.bidir_scan_offset import BidirScanOffsetSpec from raygeo.pipeline.execute import execute_stages from raygeo.pipeline.request import NodeRequest from raygeo.pipeline.stage import StageSpec @@ -1293,6 +1294,34 @@ def test_disabled_per_workpiece_transformer_not_wired( assert payload.transformers == [] +def _bidir_offset_spec(machine, step) -> BidirScanOffsetSpec: + wp = WorkPiece(name="wp") + wp.set_size(10.0, 10.0) + doc = _make_doc(step, wp) + nodes = IntentBuilder(machine=machine).build(doc) + wpk = workpiece_key(wp.uid, step.uid) + wp_node = next(n for n in nodes if n.key == wpk) + specs = [ + t + for t in wp_node.stage.params.transformers + if isinstance(t, BidirScanOffsetSpec) + ] + assert len(specs) == 1 + return specs[0] + + +def test_engrave_step_bidir_offset_reaches_transformer( + engrave_step_class, test_machine_and_config +): + """The per-step bidirectional scan offset must arrive in the + BidirScanOffset spec of the workpiece compute node.""" + machine, context = test_machine_and_config + step = engrave_step_class.create(context, name="engrave") + step.bidir_x_offset_mm = 0.3 + + assert _bidir_offset_spec(machine, step).offset_mm == pytest.approx(0.3) + + # ---------------------------------------------------------------------- # Workpiece-move cache invalidation (regression: 3D canvas stale) # ---------------------------------------------------------------------- From a22d5c73f2c6d7de3356bd4536a29b096490f397 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Fri, 9 Oct 2026 13:03:02 +0200 Subject: [PATCH 030/104] feat(machine): speed-dependent bidirectional scan offset table On machines where the laser fires with a fixed delay, the skew between left-to-right and right-to-left raster passes grows with the speed, so one offset per step only fits one speed. - Machine.bidir_offset_table holds (speed mm/min, offset mm) rows, sorted by speed, saved with the machine profile. - interpolate_bidir_offset() interpolates linearly between rows and clamps to the first and last row; an empty table means 0. - BidirScanOffsetTransformer keeps a non-zero per-step offset and otherwise looks up the step's speed in the table, which the intent builder passes along and folds into the compute token. Refs #307 --- .../bidir_scan_offset_transformer.py | 23 ++++- .../test_bidir_scan_offset_transformer.py | 53 ++++++++++ rayforge/machine/models/bidir_offset.py | 28 ++++++ rayforge/machine/models/machine.py | 29 ++++++ rayforge/pipeline/intent_builder.py | 20 +++- .../machine/models/test_bidir_offset_table.py | 98 +++++++++++++++++++ tests/pipeline/test_intent_builder.py | 41 ++++++++ 7 files changed, 286 insertions(+), 6 deletions(-) create mode 100644 rayforge/machine/models/bidir_offset.py create mode 100644 tests/machine/models/test_bidir_offset_table.py diff --git a/rayforge/builtin_addons/rayforge-addon-post/post_processors/transformers/bidir_scan_offset_transformer.py b/rayforge/builtin_addons/rayforge-addon-post/post_processors/transformers/bidir_scan_offset_transformer.py index 43f747dc4..f4349573d 100644 --- a/rayforge/builtin_addons/rayforge-addon-post/post_processors/transformers/bidir_scan_offset_transformer.py +++ b/rayforge/builtin_addons/rayforge-addon-post/post_processors/transformers/bidir_scan_offset_transformer.py @@ -5,6 +5,7 @@ from raygeo.ops.transform.bidir_scan_offset import BidirScanOffsetSpec +from rayforge.machine.models.bidir_offset import interpolate_bidir_offset from rayforge.pipeline.transformer.base import OpsTransformer if TYPE_CHECKING: @@ -24,6 +25,10 @@ class BidirScanOffsetTransformer(OpsTransformer): endpoint are shifted along X by the configured offset. Left-to-right passes are left untouched. Running after overscan means any lead-in/ lead-out already baked into the pass is shifted along with it. + + The step's own ``bidir_x_offset_mm`` wins when it is non-zero; + otherwise the offset comes from the machine's speed table + (``bidir_offset_table``), looked up at the step's ``cut_speed``. """ SPEC_NAME = "bidir_scan_offset" @@ -48,8 +53,7 @@ def to_spec( stock_geometries: list[Geometry] | None, settings: dict[str, Any] | None, ) -> BidirScanOffsetSpec: - offset = settings.get("bidir_x_offset_mm", 0.0) if settings else 0.0 - return BidirScanOffsetSpec(offset_mm=offset) + return BidirScanOffsetSpec(offset_mm=_resolve_offset(settings)) def to_dict(self) -> dict[str, Any]: return {**super().to_dict()} @@ -57,3 +61,18 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls, data: dict[str, Any]) -> BidirScanOffsetTransformer: return cls(enabled=data.get("enabled", True)) + + +def _resolve_offset(settings: dict[str, Any] | None) -> float: + """Per-step offset, else the machine table at the step's speed.""" + if not settings: + return 0.0 + offset = settings.get("bidir_x_offset_mm") or 0.0 + if offset: + return offset + speed = settings.get("cut_speed") + table = settings.get("bidir_offset_table") + if speed is None or not table: + return 0.0 + rows = sorted((float(s), float(o)) for s, o in table) + return interpolate_bidir_offset(rows, float(speed)) diff --git a/rayforge/builtin_addons/rayforge-addon-post/tests/transformers/test_bidir_scan_offset_transformer.py b/rayforge/builtin_addons/rayforge-addon-post/tests/transformers/test_bidir_scan_offset_transformer.py index 6169dc447..273232724 100644 --- a/rayforge/builtin_addons/rayforge-addon-post/tests/transformers/test_bidir_scan_offset_transformer.py +++ b/rayforge/builtin_addons/rayforge-addon-post/tests/transformers/test_bidir_scan_offset_transformer.py @@ -123,3 +123,56 @@ def test_preserves_intermediate_state_commands( assert ops.power(1) == pytest.approx(0.5) assert ops.command_type(2) == CommandType.SCAN_LINE assert ops.endpoint(2) == pytest.approx((0.3, 0.5, 0.0)) + + +TABLE = [[1000.0, 0.1], [3000.0, 0.3]] + + +@pytest.mark.parametrize( + "settings, expected", + [ + ({"bidir_x_offset_mm": 0.25}, 0.25), + ( + { + "bidir_x_offset_mm": 0.25, + "bidir_offset_table": TABLE, + "cut_speed": 2000, + }, + 0.25, + ), + ( + { + "bidir_x_offset_mm": 0.0, + "bidir_offset_table": TABLE, + "cut_speed": 2000, + }, + 0.2, + ), + ( + {"bidir_offset_table": TABLE, "cut_speed": 500}, + 0.1, + ), + ({"bidir_offset_table": TABLE}, 0.0), + ({"bidir_offset_table": [], "cut_speed": 2000}, 0.0), + ], +) +def test_step_offset_overrides_machine_table( + transformer: BidirScanOffsetTransformer, settings, expected +): + spec = transformer.to_spec(None, None, settings) + assert spec.offset_mm == pytest.approx(expected) + + +def test_machine_table_shifts_right_to_left_passes( + transformer: BidirScanOffsetTransformer, +): + ops = _build_zigzag() + + _apply( + transformer, + ops, + settings={"bidir_offset_table": TABLE, "cut_speed": 3000}, + ) + + assert ops.endpoint(2) == pytest.approx((2.3, 0.5, 0.0)) + assert ops.endpoint(3) == pytest.approx((0.3, 0.5, 0.0)) diff --git a/rayforge/machine/models/bidir_offset.py b/rayforge/machine/models/bidir_offset.py new file mode 100644 index 000000000..ac72f7146 --- /dev/null +++ b/rayforge/machine/models/bidir_offset.py @@ -0,0 +1,28 @@ +"""Speed-dependent bidirectional scan offset lookup.""" + +from itertools import pairwise + + +def interpolate_bidir_offset( + table: list[tuple[float, float]], speed: float +) -> float: + """ + Bidirectional scan offset for ``speed`` from a speed table. + + Args: + table: ``(speed mm/min, offset mm)`` rows sorted by speed. + speed: The scan speed in mm/min. + + Returns: + The offset in mm, interpolated linearly between rows and + clamped to the first and last row; 0.0 for an empty table. + """ + if not table: + return 0.0 + if speed <= table[0][0]: + return table[0][1] + for (lo_speed, lo_offset), (hi_speed, hi_offset) in pairwise(table): + if speed <= hi_speed: + t = (speed - lo_speed) / (hi_speed - lo_speed) + return lo_offset + t * (hi_offset - lo_offset) + return table[-1][1] diff --git a/rayforge/machine/models/machine.py b/rayforge/machine/models/machine.py index 848206833..55f67d100 100644 --- a/rayforge/machine/models/machine.py +++ b/rayforge/machine/models/machine.py @@ -29,6 +29,7 @@ from ..kinematics import HeadSpec, Kinematics, build_assembly from ..models.axis import AxisConfig, AxisDirection, AxisSet, AxisType from ..transport import TransportStatus +from .bidir_offset import interpolate_bidir_offset from .coordspace import MachineSpace from .dialect import GcodeDialect from .head import Head, head_from_dict @@ -181,6 +182,9 @@ def __init__(self, context: RayforgeContext): self.max_travel_speed: int = 3000 # in mm/min self.max_cut_speed: int = 1000 # in mm/min self.acceleration: int = 1000 # in mm/s² + #: (speed mm/min, offset mm) rows, sorted by speed. Engrave + #: steps without their own bidirectional scan offset use it. + self.bidir_offset_table: list[tuple[float, float]] = [] self.axes: AxisSet = AxisSet( [ AxisConfig( @@ -778,6 +782,23 @@ def set_acceleration(self, acceleration: int): self.acceleration = acceleration self.changed.send(self) + def set_bidir_offset_table(self, table) -> None: + """Set the speed-dependent bidirectional scan offset table. + + Rows are ``(speed mm/min, offset mm)`` pairs; they are sorted + by speed, and of rows with the same speed the last one wins. + """ + rows = {float(speed): float(offset) for speed, offset in table} + normalized = sorted(rows.items()) + if normalized == self.bidir_offset_table: + return + self.bidir_offset_table = normalized + self.changed.send(self) + + def bidir_offset_for_speed(self, speed: float) -> float: + """The bidirectional scan offset in mm for a speed in mm/min.""" + return interpolate_bidir_offset(self.bidir_offset_table, speed) + @property def axis_extents(self) -> tuple[float, float]: """The full range of machine axis movement (width, height).""" @@ -1806,6 +1827,10 @@ def to_dict(self, include_frozen_dialect: bool = True) -> dict[str, Any]: "max_travel_speed": self.max_travel_speed, "acceleration": self.acceleration, }, + "bidir_offset_table": [ + [speed, offset] + for speed, offset in self.bidir_offset_table + ], "gcode": { "gcode_precision": self.gcode_precision, }, @@ -2098,6 +2123,10 @@ def from_dict( "max_travel_speed", ma.max_travel_speed ) ma.acceleration = speeds.get("acceleration", ma.acceleration) + ma.bidir_offset_table = sorted( + (float(speed), float(offset)) + for speed, offset in ma_data.pop("bidir_offset_table", None) or [] + ) gcode = ma_data.pop("gcode", {}) ma.gcode_precision = gcode.get("gcode_precision", ma.gcode_precision) ma.supports_arcs = ma_data.pop("supports_arcs", ma.supports_arcs) diff --git a/rayforge/pipeline/intent_builder.py b/rayforge/pipeline/intent_builder.py index e0f97eeb8..63c6ff644 100644 --- a/rayforge/pipeline/intent_builder.py +++ b/rayforge/pipeline/intent_builder.py @@ -990,7 +990,16 @@ def _build_transformer_specs( def _bidir_offset_params(self, step: Step) -> list[Any]: """The inputs of the bidirectional scan offset, which the compute stage applies through the per-workpiece transformers.""" - return [step.cut_speed, getattr(step, "bidir_x_offset_mm", 0.0)] + table = ( + [list(row) for row in self._machine.bidir_offset_table] + if self._machine is not None + else [] + ) + return [ + step.cut_speed, + getattr(step, "bidir_x_offset_mm", 0.0), + table, + ] def _transformer_settings( self, step: Step | None = None @@ -1002,9 +1011,9 @@ def _transformer_settings( machine driver handles overscan itself, the machine kinematics so acceleration-aware transformers (e.g. the scanline merging inside :class:`Optimize`) can build their - specs from them. With a ``step`` it adds the step's speed and - its own bidirectional scan offset for - :class:`BidirScanOffsetTransformer`. + specs from them, and the machine's bidirectional scan offset + table. With a ``step`` it adds the step's speed and its own + bidirectional scan offset for :class:`BidirScanOffsetTransformer`. """ settings: dict[str, Any] = {} if self._machine is not None: @@ -1020,6 +1029,9 @@ def _transformer_settings( self._machine.max_travel_speed ), "machine_acceleration": self._machine.acceleration, + "bidir_offset_table": [ + list(row) for row in self._machine.bidir_offset_table + ], } ) if step is not None: diff --git a/tests/machine/models/test_bidir_offset_table.py b/tests/machine/models/test_bidir_offset_table.py new file mode 100644 index 000000000..b73c2d9e9 --- /dev/null +++ b/tests/machine/models/test_bidir_offset_table.py @@ -0,0 +1,98 @@ +"""Tests for the machine's speed-dependent bidirectional scan offset.""" + +import asyncio +from unittest.mock import MagicMock + +import pytest + +from rayforge.machine.models.bidir_offset import interpolate_bidir_offset +from rayforge.machine.models.machine import Machine +from rayforge.shared.tasker.manager import TaskManager + + +async def _settle(task_mgr: TaskManager): + if not await asyncio.to_thread(task_mgr.wait_until_settled, 2000): + pytest.fail("Task manager did not become idle in time.") + + +TABLE = [(1000.0, 0.10), (3000.0, 0.30), (6000.0, 0.45)] + + +class TestInterpolateBidirOffset: + def test_empty_table_is_zero(self): + assert interpolate_bidir_offset([], 2000.0) == 0.0 + + def test_single_row_is_constant(self): + table = [(2000.0, 0.2)] + assert interpolate_bidir_offset(table, 500.0) == 0.2 + assert interpolate_bidir_offset(table, 9000.0) == 0.2 + + def test_exact_rows(self): + for speed, offset in TABLE: + assert interpolate_bidir_offset(TABLE, speed) == offset + + def test_linear_between_rows(self): + assert interpolate_bidir_offset(TABLE, 2000.0) == pytest.approx(0.2) + assert interpolate_bidir_offset(TABLE, 4500.0) == pytest.approx(0.375) + + def test_clamped_outside_the_table(self): + assert interpolate_bidir_offset(TABLE, 10.0) == 0.10 + assert interpolate_bidir_offset(TABLE, 99999.0) == 0.45 + + def test_negative_offsets(self): + table = [(1000.0, -0.1), (2000.0, 0.1)] + assert interpolate_bidir_offset(table, 1500.0) == pytest.approx(0.0) + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("lite_context") +class TestMachineBidirOffsetTable: + async def test_defaults_to_empty(self, machine: Machine): + assert machine.bidir_offset_table == [] + assert machine.bidir_offset_for_speed(3000) == 0.0 + + async def test_set_sorts_and_signals(self, machine: Machine): + spy = MagicMock() + machine.changed.connect(spy) + machine.set_bidir_offset_table([(3000, 0.3), (1000, 0.1)]) + assert machine.bidir_offset_table == [(1000.0, 0.1), (3000.0, 0.3)] + spy.assert_called_once_with(machine) + + spy.reset_mock() + machine.set_bidir_offset_table([(1000.0, 0.1), (3000.0, 0.3)]) + spy.assert_not_called() + + async def test_duplicate_speeds_keep_the_last_row(self, machine: Machine): + machine.set_bidir_offset_table([(1000, 0.1), (1000, 0.2)]) + assert machine.bidir_offset_table == [(1000.0, 0.2)] + + async def test_lookup_uses_the_table(self, machine: Machine): + machine.set_bidir_offset_table([(1000, 0.1), (3000, 0.3)]) + assert machine.bidir_offset_for_speed(2000) == pytest.approx(0.2) + + async def test_serialization_roundtrip( + self, machine: Machine, task_mgr: TaskManager, lite_context + ): + await _settle(task_mgr) + machine.set_bidir_offset_table([(1000, 0.1), (3000, 0.3)]) + data = machine.to_dict() + assert data["machine"]["bidir_offset_table"] == [ + [1000.0, 0.1], + [3000.0, 0.3], + ] + restored = Machine.from_dict(data, context=lite_context) + await _settle(task_mgr) + assert restored.bidir_offset_table == [(1000.0, 0.1), (3000.0, 0.3)] + await restored.shutdown() + + async def test_old_profiles_load_without_table( + self, machine: Machine, task_mgr: TaskManager, lite_context + ): + await _settle(task_mgr) + data = machine.to_dict() + data["machine"].pop("bidir_offset_table", None) + restored = Machine.from_dict(data, context=lite_context) + await _settle(task_mgr) + assert restored.bidir_offset_table == [] + assert "bidir_offset_table" not in restored.extra + await restored.shutdown() diff --git a/tests/pipeline/test_intent_builder.py b/tests/pipeline/test_intent_builder.py index b0376c569..f0f5d44b8 100644 --- a/tests/pipeline/test_intent_builder.py +++ b/tests/pipeline/test_intent_builder.py @@ -1322,6 +1322,47 @@ def test_engrave_step_bidir_offset_reaches_transformer( assert _bidir_offset_spec(machine, step).offset_mm == pytest.approx(0.3) +def test_engrave_step_falls_back_to_machine_offset_table( + engrave_step_class, test_machine_and_config +): + """Without a per-step offset the machine's speed table decides, + interpolated at the step's engrave speed.""" + machine, context = test_machine_and_config + machine.set_bidir_offset_table([(1000, 0.1), (3000, 0.3)]) + step = engrave_step_class.create(context, name="engrave") + step.bidir_x_offset_mm = 0.0 + step.cut_speed = 2000 + + assert _bidir_offset_spec(machine, step).offset_mm == pytest.approx(0.2) + + +def test_bidir_offset_changes_invalidate_the_compute_node( + engrave_step_class, test_machine_and_config +): + """The offset is applied inside the compute stage, so changing it + on the step or in the machine table must change the node token.""" + machine, context = test_machine_and_config + step = engrave_step_class.create(context, name="engrave") + step.cut_speed = 2000 + wp = WorkPiece(name="wp") + wp.set_size(10.0, 10.0) + doc = _make_doc(step, wp) + wpk = workpiece_key(wp.uid, step.uid) + + def token(): + nodes = IntentBuilder(machine=machine).build(doc) + return next(n for n in nodes if n.key == wpk).version_token + + base = token() + step.bidir_x_offset_mm = 0.2 + with_step_offset = token() + step.bidir_x_offset_mm = 0.0 + machine.set_bidir_offset_table([(1000, 0.1), (3000, 0.3)]) + with_table = token() + + assert len({base, with_step_offset, with_table}) == 3 + + # ---------------------------------------------------------------------- # Workpiece-move cache invalidation (regression: 3D canvas stale) # ---------------------------------------------------------------------- From 4f626b0ce46559f0f6e27ccbf7a39b486d892dca Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Fri, 9 Oct 2026 13:03:02 +0200 Subject: [PATCH 031/104] feat(ui): edit the bidirectional scan offset table in machine settings The Advanced page of the machine settings gains a "Bidirectional Scan Offset by Speed" group: one expandable row per speed with unit-aware speed and offset fields, an add button that appends a row 1000 mm/min above the fastest one (or at the maximum cut speed for an empty table) and a remove button per row. Refs #307 --- .../machine/advanced_preferences_page.py | 4 + rayforge/ui_gtk/machine/bidir_offset_table.py | 141 ++++++++++++++++++ .../machine/test_bidir_offset_table_page.py | 82 ++++++++++ 3 files changed, 227 insertions(+) create mode 100644 rayforge/ui_gtk/machine/bidir_offset_table.py create mode 100644 tests/ui_gtk/machine/test_bidir_offset_table_page.py diff --git a/rayforge/ui_gtk/machine/advanced_preferences_page.py b/rayforge/ui_gtk/machine/advanced_preferences_page.py index 4263ca6ef..99f35fc42 100644 --- a/rayforge/ui_gtk/machine/advanced_preferences_page.py +++ b/rayforge/ui_gtk/machine/advanced_preferences_page.py @@ -5,6 +5,7 @@ from ..shared.pref_rows.length_spin_row import LengthSpinRow from ..shared.preferences_page import TrackedPreferencesPage +from .bidir_offset_table import BidirOffsetTableGroup logger = logging.getLogger(__name__) @@ -68,6 +69,9 @@ def __init__(self, machine, **kwargs): ) path_group.add(self.arc_tolerance_row) + self.bidir_offset_group = BidirOffsetTableGroup(self.machine) + self.add(self.bidir_offset_group) + homing_group = Adw.PreferencesGroup(title=_("Homing and Startup")) homing_group.set_description( _( diff --git a/rayforge/ui_gtk/machine/bidir_offset_table.py b/rayforge/ui_gtk/machine/bidir_offset_table.py new file mode 100644 index 000000000..3f89fc831 --- /dev/null +++ b/rayforge/ui_gtk/machine/bidir_offset_table.py @@ -0,0 +1,141 @@ +"""Editor for the machine's speed-dependent bidirectional scan offset.""" + +from gettext import gettext as _ + +from gi.repository import Adw, Gtk + +from ...context import get_context +from ...shared.units.definitions import get_unit +from ..shared.pref_rows.length_spin_row import LengthSpinRow +from ..shared.pref_rows.speed_spin_row import SpeedSpinRow + +#: Speed added above the fastest row when the user adds a row. +SPEED_STEP = 1000.0 + + +def _format(quantity: str, value: float) -> str: + unit_name = get_context().config.unit_preferences.get(quantity) + unit = get_unit(unit_name) if unit_name else None + if unit is None: + return f"{value:g}" + shown = unit.from_base(value) + return f"{shown:.{unit.precision}f} {unit.label}" + + +class _OffsetEntryRow(Adw.ExpanderRow): + """One ``(speed, offset)`` row of the table.""" + + def __init__(self, speed: float, offset: float, on_change, on_remove): + super().__init__() + self.speed_row = SpeedSpinRow( + _("Speed"), + lower=1.0, + upper=1_000_000.0, + value_in_base=speed, + ) + self.offset_row = LengthSpinRow( + _("Offset"), + _("Positive values shift right-to-left passes to the right"), + lower=-5.0, + upper=5.0, + step_increment=0.001, + digits=3, + value_in_base=offset, + ) + self.add_row(self.speed_row) + self.add_row(self.offset_row) + + self.remove_button = Gtk.Button(icon_name="user-trash-symbolic") + self.remove_button.set_valign(Gtk.Align.CENTER) + self.remove_button.add_css_class("flat") + self.remove_button.set_tooltip_text(_("Remove Row")) + self.remove_button.connect("clicked", lambda _b: on_remove(self)) + self.add_suffix(self.remove_button) + + self.speed_row.value_changed.connect(lambda _r: on_change()) + self.offset_row.value_changed.connect(lambda _r: on_change()) + self.update_title() + + @property + def entry(self) -> tuple[float, float]: + return ( + self.speed_row.get_value_in_base_units(), + self.offset_row.get_value_in_base_units(), + ) + + def update_title(self) -> None: + speed, offset = self.entry + self.set_title( + _("{speed}: {offset}").format( + speed=_format("speed", speed), + offset=_format("length", offset), + ) + ) + + +class BidirOffsetTableGroup(Adw.PreferencesGroup): + """Edits ``Machine.bidir_offset_table``.""" + + def __init__(self, machine): + super().__init__(title=_("Bidirectional Scan Offset by Speed")) + self.set_description( + _( + "Shift of right-to-left raster passes at each engrave " + "speed, interpolated between rows. Engrave steps use it " + "when their own Bidirectional Scan Offset is 0." + ) + ) + self.machine = machine + self.entry_rows: list[_OffsetEntryRow] = [] + + self.add_button = Gtk.Button(icon_name="list-add-symbolic") + self.add_button.add_css_class("flat") + self.add_button.set_tooltip_text(_("Add Row")) + self.add_button.connect("clicked", self._on_add_clicked) + self.set_header_suffix(self.add_button) + + self.empty_row = Adw.ActionRow( + title=_("No rows: all speeds use an offset of 0") + ) + self.empty_row.add_css_class("dim-label") + self.add(self.empty_row) + + self._rebuild() + + def _rebuild(self) -> None: + for row in self.entry_rows: + self.remove(row) + self.entry_rows = [] + for speed, offset in self.machine.bidir_offset_table: + row = _OffsetEntryRow( + speed, offset, self._on_row_changed, self._on_remove + ) + self.entry_rows.append(row) + self.add(row) + self.empty_row.set_visible(not self.entry_rows) + + def _commit(self) -> None: + self.machine.set_bidir_offset_table( + [row.entry for row in self.entry_rows] + ) + + def _on_row_changed(self) -> None: + for row in self.entry_rows: + row.update_title() + self._commit() + + def _on_add_clicked(self, _button) -> None: + table = self.machine.bidir_offset_table + if table: + speed, offset = table[-1] + entry = (speed + SPEED_STEP, offset) + else: + entry = (float(self.machine.max_cut_speed), 0.0) + self.machine.set_bidir_offset_table([*table, entry]) + self._rebuild() + + def _on_remove(self, row: _OffsetEntryRow) -> None: + self.remove(row) + self.entry_rows.remove(row) + self._commit() + self._rebuild() diff --git a/tests/ui_gtk/machine/test_bidir_offset_table_page.py b/tests/ui_gtk/machine/test_bidir_offset_table_page.py new file mode 100644 index 000000000..959de65a6 --- /dev/null +++ b/tests/ui_gtk/machine/test_bidir_offset_table_page.py @@ -0,0 +1,82 @@ +"""Tests for the bidirectional scan offset table in Machine Settings.""" + +import pytest + +from rayforge.machine.models.machine import Machine +from rayforge.ui_gtk.machine.advanced_preferences_page import ( + AdvancedPreferencesPage, +) +from rayforge.ui_gtk.machine.bidir_offset_table import BidirOffsetTableGroup + +pytestmark = pytest.mark.ui + + +def _group(machine) -> BidirOffsetTableGroup: + page = AdvancedPreferencesPage(machine) + assert isinstance(page.bidir_offset_group, BidirOffsetTableGroup) + return page.bidir_offset_group + + +def test_empty_table_shows_placeholder(ui_context_initializer): + machine = Machine(ui_context_initializer) + group = _group(machine) + + assert group.entry_rows == [] + assert group.empty_row.get_visible() is True + + +def test_rows_mirror_the_machine_table(ui_context_initializer): + machine = Machine(ui_context_initializer) + machine.set_bidir_offset_table([(1000, 0.1), (3000, 0.3)]) + group = _group(machine) + + assert len(group.entry_rows) == 2 + assert group.empty_row.get_visible() is False + first = group.entry_rows[0] + assert first.speed_row.get_value_in_base_units() == pytest.approx(1000) + assert first.offset_row.get_value_in_base_units() == pytest.approx(0.1) + + +def test_add_appends_a_row_above_the_fastest_speed(ui_context_initializer): + machine = Machine(ui_context_initializer) + machine.set_bidir_offset_table([(1000, 0.1)]) + group = _group(machine) + + group.add_button.emit("clicked") + + assert machine.bidir_offset_table == [(1000.0, 0.1), (2000.0, 0.1)] + assert len(group.entry_rows) == 2 + + +def test_add_on_empty_table_uses_max_cut_speed(ui_context_initializer): + machine = Machine(ui_context_initializer) + machine.max_cut_speed = 3000 + group = _group(machine) + + group.add_button.emit("clicked") + + assert machine.bidir_offset_table == [(3000.0, 0.0)] + + +def test_editing_a_row_updates_the_machine(ui_context_initializer): + machine = Machine(ui_context_initializer) + machine.set_bidir_offset_table([(1000, 0.1), (3000, 0.3)]) + group = _group(machine) + + group.entry_rows[1].offset_row.set_value_in_base_units(0.25) + group.entry_rows[1].offset_row.value_changed.send( + group.entry_rows[1].offset_row + ) + + assert machine.bidir_offset_table == [(1000.0, 0.1), (3000.0, 0.25)] + + +def test_remove_drops_the_row(ui_context_initializer): + machine = Machine(ui_context_initializer) + machine.set_bidir_offset_table([(1000, 0.1), (3000, 0.3)]) + group = _group(machine) + + group.entry_rows[0].remove_button.emit("clicked") + + assert machine.bidir_offset_table == [(3000.0, 0.3)] + assert len(group.entry_rows) == 1 From c4f43af60ecc01d4f524377454ba0ff6f23d56b2 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Sun, 11 Oct 2026 00:32:55 +0200 Subject: [PATCH 032/104] refactor(doceditor): use raygeo's path clean-up operations The geometry manipulation core moves into raygeo (barebaric/raygeo#56) as suggested in review: the editor command now calls the Geometry methods close_open_contours, join_open_contours, remove_duplicate_contours, matches and split_drawn_contours, and the core module with its unit tests moves there. The command, undo integration and UI stay here. Needs a raygeo release that contains barebaric/raygeo#56. --- rayforge/core/path_cleanup.py | 346 ------------------------- rayforge/doceditor/path_cleanup_cmd.py | 20 +- rayforge/doceditor/split_cmd.py | 3 +- tests/core/test_path_cleanup.py | 328 ----------------------- 4 files changed, 11 insertions(+), 686 deletions(-) delete mode 100644 rayforge/core/path_cleanup.py delete mode 100644 tests/core/test_path_cleanup.py diff --git a/rayforge/core/path_cleanup.py b/rayforge/core/path_cleanup.py deleted file mode 100644 index 3a41b73d0..000000000 --- a/rayforge/core/path_cleanup.py +++ /dev/null @@ -1,346 +0,0 @@ -""" -Clean-up operations for vector paths. - -These functions close, join, de-duplicate and split the contours of a -:class:`~raygeo.geo.Geometry`. They never move existing points: gaps are -bridged with a straight segment between two existing end points, so the -bounding box of the result never grows. All distances are expressed in -the units of the geometry passed in; callers that work on normalized -workpiece geometry should scale it to millimetres first. -""" - -import math -from collections.abc import Iterator - -from raygeo.geo import Geometry, Move - -Point = tuple[float, float] - -_CLOSED_EPS = 1e-6 -_DUPLICATE_SAMPLES = 32 - - -def _start_point(contour: Geometry) -> Point: - first = contour.data[0] - return first.end[0], first.end[1] - - -def _end_point(contour: Geometry) -> Point: - x, y, _z = contour.get_last_point() - return x, y - - -def _distance(a: Point, b: Point) -> float: - return math.hypot(a[0] - b[0], a[1] - b[1]) - - -def _segment_count(contour: Geometry) -> int: - return sum(1 for cmd in contour.data if not isinstance(cmd, Move)) - - -def _drawn_contours(geo: Geometry) -> list[Geometry]: - """Splits into contours, dropping those that draw nothing.""" - if geo.is_empty(): - return [] - return [c for c in geo.split_into_contours() if _segment_count(c) > 0] - - -def _is_open(contour: Geometry) -> bool: - return not contour.is_closed(_CLOSED_EPS) - - -def _close_if_near(contour: Geometry, tolerance: float) -> bool: - """ - Closes an open contour of at least two segments in place when its - start and end are within the tolerance. Returns True if it closed it. - """ - if not _is_open(contour) or _segment_count(contour) < 2: - return False - start = _start_point(contour) - if _distance(start, _end_point(contour)) > tolerance: - return False - contour.line_to(start[0], start[1]) - return True - - -def _concat(contours: list[Geometry]) -> Geometry: - result = Geometry() - for contour in contours: - result.extend(contour) - return result - - -def close_open_contours( - geo: Geometry, tolerance: float -) -> tuple[Geometry, int]: - """ - Closes every open contour whose start and end point lie within the - tolerance of each other. - - Args: - geo: The geometry to process. It is not modified. - tolerance: The largest gap that is closed. - - Returns: - A tuple of the new geometry and the number of closed contours. - """ - contours = [c.copy() for c in _drawn_contours(geo)] - closed = sum(1 for c in contours if _close_if_near(c, tolerance)) - return _concat(contours), closed - - -class _EndpointGrid: - """A spatial hash of open contour end points for neighbour lookups.""" - - def __init__(self, cell_size: float): - self._cell = max(cell_size, 1e-9) - self._cells: dict[tuple[int, int], set[tuple[int, int]]] = {} - self._points: dict[tuple[int, int], Point] = {} - - def _key(self, p: Point) -> tuple[int, int]: - return math.floor(p[0] / self._cell), math.floor(p[1] / self._cell) - - def add(self, index: int, end: int, p: Point): - self._points[(index, end)] = p - self._cells.setdefault(self._key(p), set()).add((index, end)) - - def remove_contour(self, index: int): - for end in (0, 1): - p = self._points.pop((index, end), None) - if p is not None: - self._cells[self._key(p)].discard((index, end)) - - def _neighbours(self, p: Point) -> Iterator[tuple[int, int]]: - kx, ky = self._key(p) - for dx in (-1, 0, 1): - for dy in (-1, 0, 1): - yield from self._cells.get((kx + dx, ky + dy), ()) - - def nearest(self, p: Point, tolerance: float) -> tuple[int, int] | None: - best: tuple[int, int] | None = None - best_dist = tolerance - for entry in sorted(self._neighbours(p)): - d = _distance(p, self._points[entry]) - if d <= best_dist: - best, best_dist = entry, d - return best - - -def _append_contour(chain: Geometry, piece: Geometry): - """Appends a piece to the end of a chain, bridging any gap.""" - start = _start_point(piece) - if _distance(_end_point(chain), start) > 0.0: - chain.line_to(start[0], start[1]) - chain.extend(piece.filter(set(range(1, len(piece.data))))) - - -def _grow_tail( - chain: Geometry, - contours: list[Geometry], - grid: _EndpointGrid, - tolerance: float, - consumed: set[int], -) -> int: - """ - Keeps appending the nearest open contour to the chain's end, and - records the appended contours in ``consumed``. - """ - joins = 0 - while True: - match = grid.nearest(_end_point(chain), tolerance) - if match is None: - return joins - index, end = match - grid.remove_contour(index) - consumed.add(index) - piece = contours[index].copy() - if end == 1: - piece.reverse_contour() - _append_contour(chain, piece) - joins += 1 - - -def join_open_contours( - geo: Geometry, tolerance: float -) -> tuple[Geometry, int]: - """ - Joins open contours whose end points meet within the tolerance into - longer contours, reversing contours where needed. A joined contour - whose own ends then meet within the tolerance is closed. Closed - contours are left untouched. - - Args: - geo: The geometry to process. It is not modified. - tolerance: The largest gap between two end points that is joined. - - Returns: - A tuple of the new geometry and the number of joins made. - """ - contours = _drawn_contours(geo) - grid = _EndpointGrid(tolerance) - open_indices = [i for i, c in enumerate(contours) if _is_open(c)] - for i in open_indices: - grid.add(i, 0, _start_point(contours[i])) - grid.add(i, 1, _end_point(contours[i])) - - chains: dict[int, Geometry] = {} - consumed: set[int] = set() - joins = 0 - for i in open_indices: - if i in consumed: - continue - grid.remove_contour(i) - chain = contours[i].copy() - joins += _grow_tail(chain, contours, grid, tolerance, consumed) - chain.reverse_contour() - joins += _grow_tail(chain, contours, grid, tolerance, consumed) - chain.reverse_contour() - _close_if_near(chain, tolerance) - chains[i] = chain - - result = Geometry() - for i, contour in enumerate(contours): - if i in chains: - result.extend(chains[i]) - elif i not in consumed: - result.extend(contour) - return result, joins - - -def _sample_points(contour: Geometry) -> list[Point]: - """The contour's vertices plus evenly spaced points along it.""" - points = [(cmd.end[0], cmd.end[1]) for cmd in contour.data] - length = contour.distance() - if length > 0.0: - step = length / _DUPLICATE_SAMPLES - distances = [step * k for k in range(_DUPLICATE_SAMPLES + 1)] - for _idx, _t, p in contour.get_positions_at_distances(distances): - points.append((p[0], p[1])) - return points - - -def _covers(target: Geometry, points: list[Point], tolerance: float) -> bool: - for x, y in points: - hit = target.find_closest_point(x, y) - if hit is None or _distance((x, y), hit[2]) > tolerance: - return False - return True - - -def _rects_match(a: Geometry, b: Geometry, tolerance: float) -> bool: - return all(abs(va - vb) <= tolerance for va, vb in zip(a.rect(), b.rect())) - - -def _contours_match(a: Geometry, b: Geometry, tolerance: float) -> bool: - """ - True if two contours trace the same path within the tolerance, - regardless of their direction or start point. - """ - if _is_open(a) != _is_open(b): - return False - if not _rects_match(a, b, tolerance): - return False - return _covers(b, _sample_points(a), tolerance) and _covers( - a, _sample_points(b), tolerance - ) - - -def _length_slack(tolerance: float) -> float: - return 4.0 * tolerance + 1e-9 - - -class _CornerIndex: - """ - A spatial hash of the lower-left bounding box corner of kept - contours. Duplicates have corners within the tolerance, so only - neighbouring cells need to be compared. - """ - - def __init__(self, tolerance: float): - self._cell = max(tolerance, 1e-9) - self._cells: dict[tuple[int, int], list[int]] = {} - - def _key(self, x: float, y: float) -> tuple[int, int]: - return math.floor(x / self._cell), math.floor(y / self._cell) - - def add(self, index: int, rect: tuple[float, float, float, float]): - self._cells.setdefault(self._key(rect[0], rect[1]), []).append(index) - - def near(self, rect: tuple[float, float, float, float]) -> Iterator[int]: - kx, ky = self._key(rect[0], rect[1]) - for dx in (-1, 0, 1): - for dy in (-1, 0, 1): - yield from self._cells.get((kx + dx, ky + dy), ()) - - -def remove_duplicate_contours( - geo: Geometry, tolerance: float -) -> tuple[Geometry, int]: - """ - Removes contours that duplicate an earlier contour within the - tolerance, regardless of direction or start point. The first - occurrence is kept and the order of the remaining contours is - preserved. - - Args: - geo: The geometry to process. It is not modified. - tolerance: The largest distance between two matching paths. - - Returns: - A tuple of the new geometry and the number of removed contours. - """ - contours = _drawn_contours(geo) - slack = _length_slack(tolerance) - index = _CornerIndex(tolerance) - lengths: list[float] = [] - kept: list[Geometry] = [] - removed = 0 - for contour in contours: - rect = contour.rect() - length = contour.distance() - if any( - abs(lengths[k] - length) <= slack - and _contours_match(kept[k], contour, tolerance) - for k in index.near(rect) - ): - removed += 1 - continue - index.add(len(kept), rect) - lengths.append(length) - kept.append(contour) - return _concat(kept), removed - - -def geometries_match(a: Geometry, b: Geometry, tolerance: float) -> bool: - """ - True if both geometries consist of the same contours within the - tolerance, in any order, direction or start point. Empty geometries - never match. - """ - contours_a = _drawn_contours(a) - contours_b = _drawn_contours(b) - if not contours_a or len(contours_a) != len(contours_b): - return False - unmatched = list(contours_b) - for contour in contours_a: - found = next( - ( - other - for other in unmatched - if _contours_match(contour, other, tolerance) - ), - None, - ) - if found is None: - return False - unmatched.remove(found) - return True - - -def split_contours(geo: Geometry) -> list[Geometry]: - """ - Splits a geometry into one geometry per contour. Unlike splitting - into connected components, holes become parts of their own and open - contours are kept. - """ - return [c.copy() for c in _drawn_contours(geo)] diff --git a/rayforge/doceditor/path_cleanup_cmd.py b/rayforge/doceditor/path_cleanup_cmd.py index 2fc3ef152..1ca509333 100644 --- a/rayforge/doceditor/path_cleanup_cmd.py +++ b/rayforge/doceditor/path_cleanup_cmd.py @@ -6,12 +6,6 @@ from raygeo.geo import Geometry, Matrix from ..core.item import DocItem -from ..core.path_cleanup import ( - close_open_contours, - geometries_match, - join_open_contours, - remove_duplicate_contours, -) from ..core.undo import ChangePropertyCommand, ListItemCommand from ..core.workpiece import WorkPiece from .split_cmd import ContourSplitStrategy @@ -83,7 +77,10 @@ def close_paths( The number of closed contours. """ return self._apply( - workpieces, close_open_contours, tolerance_mm, _("Close paths") + workpieces, + Geometry.close_open_contours, + tolerance_mm, + _("Close paths"), ) def join_paths( @@ -96,7 +93,10 @@ def join_paths( The number of joins made. """ return self._apply( - workpieces, join_open_contours, tolerance_mm, _("Join paths") + workpieces, + Geometry.join_open_contours, + tolerance_mm, + _("Join paths"), ) def delete_duplicates( @@ -115,7 +115,7 @@ def delete_duplicates( duplicates = self._find_duplicate_workpieces(workpieces, tolerance_mm) remaining = [wp for wp in workpieces if wp not in duplicates] changes = self._compute_changes( - remaining, remove_duplicate_contours, tolerance_mm + remaining, Geometry.remove_duplicate_contours, tolerance_mm ) if not duplicates and not changes: return 0 @@ -223,7 +223,7 @@ def _find_duplicate_workpieces( continue if any( _rects_overlap(geo, other, tolerance_mm) - and geometries_match(geo, other, tolerance_mm) + and geo.matches(other, tolerance_mm) for _wp, other in kept ): duplicates.append(workpiece) diff --git a/rayforge/doceditor/split_cmd.py b/rayforge/doceditor/split_cmd.py index 03f4b9282..884df1af0 100644 --- a/rayforge/doceditor/split_cmd.py +++ b/rayforge/doceditor/split_cmd.py @@ -7,7 +7,6 @@ from raygeo.geo import Geometry from ..core.item import DocItem -from ..core.path_cleanup import split_contours from ..core.undo import ListItemCommand from ..core.workpiece import WorkPiece @@ -60,7 +59,7 @@ class ContourSplitStrategy(SplitStrategy): def calculate_fragments(self, workpiece: "WorkPiece") -> list[Geometry]: if not workpiece.boundaries or workpiece.boundaries.is_empty(): return [] - return split_contours(workpiece.boundaries) + return workpiece.boundaries.split_drawn_contours() class SplitCmd: diff --git a/tests/core/test_path_cleanup.py b/tests/core/test_path_cleanup.py deleted file mode 100644 index 9f01a9699..000000000 --- a/tests/core/test_path_cleanup.py +++ /dev/null @@ -1,328 +0,0 @@ -import math - -import pytest -from raygeo.geo import Geometry - -from rayforge.core.path_cleanup import ( - close_open_contours, - geometries_match, - join_open_contours, - remove_duplicate_contours, - split_contours, -) - - -def _polyline(points, close=False) -> Geometry: - geo = Geometry() - geo.move_to(*points[0]) - for p in points[1:]: - geo.line_to(*p) - if close: - geo.line_to(*points[0]) - return geo - - -def _concat(*geos: Geometry) -> Geometry: - result = Geometry() - for geo in geos: - result.extend(geo) - return result - - -def _contours(geo: Geometry) -> list[Geometry]: - return geo.split_into_contours() - - -def _start(geo: Geometry) -> tuple[float, float]: - first = next(iter(geo.iter_typed_commands())) - return first.end[0], first.end[1] - - -def _end(geo: Geometry) -> tuple[float, float]: - x, y, _z = geo.get_last_point() - return x, y - - -def _dist(a, b) -> float: - return math.hypot(a[0] - b[0], a[1] - b[1]) - - -SQUARE = [(0, 0), (10, 0), (10, 10), (0, 10)] - - -class TestCloseOpenContours: - def test_closes_contour_within_tolerance(self): - geo = _polyline(SQUARE + [(0, 0.05)]) - result, count = close_open_contours(geo, 0.1) - assert count == 1 - contours = _contours(result) - assert len(contours) == 1 - assert contours[0].is_closed() - - def test_leaves_contour_outside_tolerance_open(self): - geo = _polyline(SQUARE + [(0, 0.5)]) - result, count = close_open_contours(geo, 0.1) - assert count == 0 - assert not _contours(result)[0].is_closed() - - def test_does_not_move_existing_points(self): - geo = _polyline(SQUARE + [(0, 0.05)]) - result, _count = close_open_contours(geo, 0.1) - assert result.rect() == pytest.approx(geo.rect()) - - def test_already_closed_contour_is_untouched(self): - geo = _polyline(SQUARE, close=True) - result, count = close_open_contours(geo, 0.1) - assert count == 0 - assert len(result.data) == len(geo.data) - - def test_handles_each_contour_separately(self): - geo = _concat( - _polyline(SQUARE + [(0, 0.05)]), - _polyline([(20, 0), (30, 0), (30, 10), (20, 3)]), - _polyline([(40, 0), (50, 0), (50, 10), (40, 0.02)]), - ) - result, count = close_open_contours(geo, 0.1) - assert count == 2 - closed = [c.is_closed() for c in _contours(result)] - assert closed == [True, False, True] - - def test_ignores_degenerate_single_segment(self): - geo = _polyline([(0, 0), (0.05, 0)]) - _result, count = close_open_contours(geo, 0.1) - assert count == 0 - - def test_empty_geometry(self): - result, count = close_open_contours(Geometry(), 0.1) - assert count == 0 - assert result.is_empty() - - -class TestJoinOpenContours: - def test_joins_two_pieces_in_order(self): - geo = _concat( - _polyline([(0, 0), (10, 0), (10, 10)]), - _polyline([(10, 10.05), (0, 10), (0, 0.05)]), - ) - result, joins = join_open_contours(geo, 0.1) - assert joins == 1 - contours = _contours(result) - assert len(contours) == 1 - assert contours[0].is_closed() - - def test_joins_reversed_piece(self): - geo = _concat( - _polyline([(0, 0), (10, 0)]), - _polyline([(20, 0), (10.02, 0)]), - ) - result, joins = join_open_contours(geo, 0.1) - assert joins == 1 - contours = _contours(result) - assert len(contours) == 1 - ends = {_start(contours[0]), _end(contours[0])} - assert ends == {(0.0, 0.0), (20.0, 0.0)} - - def test_joins_pieces_out_of_order(self): - geo = _concat( - _polyline([(0, 0), (10, 0)]), - _polyline([(20, 0), (30, 0)]), - _polyline([(10, 0), (20, 0)]), - ) - result, joins = join_open_contours(geo, 0.1) - assert joins == 2 - contours = _contours(result) - assert len(contours) == 1 - assert result.distance() == pytest.approx(30.0) - - def test_joins_at_the_start_of_a_piece(self): - geo = _concat( - _polyline([(10, 0), (20, 0)]), - _polyline([(0, 0), (10, 0)]), - ) - result, joins = join_open_contours(geo, 0.1) - assert joins == 1 - contours = _contours(result) - assert len(contours) == 1 - assert {_start(contours[0]), _end(contours[0])} == { - (0.0, 0.0), - (20.0, 0.0), - } - - def test_keeps_pieces_apart_beyond_tolerance(self): - geo = _concat( - _polyline([(0, 0), (10, 0)]), - _polyline([(11, 0), (20, 0)]), - ) - result, joins = join_open_contours(geo, 0.1) - assert joins == 0 - assert len(_contours(result)) == 2 - - def test_leaves_closed_contours_alone(self): - square = _polyline(SQUARE, close=True) - line = _polyline([(0, 0), (-10, 0)]) - geo = _concat(square, line) - result, joins = join_open_contours(geo, 0.1) - assert joins == 0 - assert len(_contours(result)) == 2 - - def test_closes_single_piece_whose_ends_meet(self): - geo = _polyline(SQUARE + [(0, 0.05)]) - result, _joins = join_open_contours(geo, 0.1) - assert _contours(result)[0].is_closed() - - def test_bridge_does_not_change_bounds(self): - geo = _concat( - _polyline([(0, 0), (10, 0), (10, 10)]), - _polyline([(10.05, 10), (0, 10), (0, 0)]), - ) - result, _joins = join_open_contours(geo, 0.1) - assert result.rect() == pytest.approx(geo.rect()) - - def test_many_segments_form_one_closed_outline(self): - pts = [ - (10 * math.cos(a), 10 * math.sin(a)) - for a in (2 * math.pi * i / 60 for i in range(60)) - ] - pieces = [] - for i in range(60): - a, b = pts[i], pts[(i + 1) % 60] - if i % 2: - a, b = b, a - pieces.append(_polyline([a, b])) - geo = _concat(*reversed(pieces)) - result, joins = join_open_contours(geo, 0.01) - assert joins == 59 - contours = _contours(result) - assert len(contours) == 1 - assert contours[0].is_closed() - - -class TestRemoveDuplicateContours: - def test_removes_identical_copy(self): - geo = _concat( - _polyline(SQUARE, close=True), _polyline(SQUARE, close=True) - ) - result, removed = remove_duplicate_contours(geo, 0.01) - assert removed == 1 - assert len(_contours(result)) == 1 - - def test_removes_reversed_copy(self): - geo = _concat( - _polyline(SQUARE, close=True), - _polyline(list(reversed(SQUARE)), close=True), - ) - _result, removed = remove_duplicate_contours(geo, 0.01) - assert removed == 1 - - def test_removes_copy_with_other_start_point(self): - shifted = SQUARE[2:] + SQUARE[:2] - geo = _concat( - _polyline(SQUARE, close=True), _polyline(shifted, close=True) - ) - _result, removed = remove_duplicate_contours(geo, 0.01) - assert removed == 1 - - def test_removes_open_reversed_copy(self): - line = [(0, 0), (5, 5), (10, 0)] - geo = _concat(_polyline(line), _polyline(list(reversed(line)))) - _result, removed = remove_duplicate_contours(geo, 0.01) - assert removed == 1 - - def test_keeps_shapes_that_only_overlap_partly(self): - geo = _concat( - _polyline(SQUARE, close=True), - _polyline([(0, 0), (10, 0), (10, 10)]), - ) - result, removed = remove_duplicate_contours(geo, 0.01) - assert removed == 0 - assert len(_contours(result)) == 2 - - def test_keeps_nested_shapes(self): - inner = [(2, 2), (8, 2), (8, 8), (2, 8)] - geo = _concat( - _polyline(SQUARE, close=True), _polyline(inner, close=True) - ) - _result, removed = remove_duplicate_contours(geo, 0.01) - assert removed == 0 - - def test_tolerance_applies(self): - nudged = [(x + 0.005, y) for x, y in SQUARE] - geo = _concat( - _polyline(SQUARE, close=True), _polyline(nudged, close=True) - ) - assert remove_duplicate_contours(geo, 0.01)[1] == 1 - assert remove_duplicate_contours(geo, 0.001)[1] == 0 - - def test_keeps_first_occurrence_and_order(self): - other = [(20, 0), (30, 0), (30, 10)] - geo = _concat( - _polyline(SQUARE, close=True), - _polyline(other), - _polyline(SQUARE, close=True), - ) - result, removed = remove_duplicate_contours(geo, 0.01) - assert removed == 1 - contours = _contours(result) - assert _start(contours[0]) == (0.0, 0.0) - assert _start(contours[1]) == (20.0, 0.0) - - def test_removes_triple_copies(self): - geo = _concat(*(_polyline(SQUARE, close=True) for _ in range(3))) - _result, removed = remove_duplicate_contours(geo, 0.01) - assert removed == 2 - - def test_many_scattered_duplicates(self): - pieces = [] - for i in range(200): - x, y = (i * 37) % 290, (i * 53) % 290 - pieces.append(_polyline([(x, y), (x + 5, y)])) - copies = [ - _polyline(list(reversed([(x, y), (x + 5, y)]))) - for x, y in (((i * 37) % 290, (i * 53) % 290) for i in range(200)) - ] - geo = _concat(*pieces, *copies) - result, removed = remove_duplicate_contours(geo, 0.01) - assert removed == 200 - assert len(_contours(result)) == 200 - - -class TestGeometriesMatch: - def test_same_shapes_in_other_order_match(self): - a = _concat( - _polyline(SQUARE, close=True), _polyline([(20, 0), (30, 0)]) - ) - b = _concat( - _polyline([(30, 0), (20, 0)]), _polyline(SQUARE, close=True) - ) - assert geometries_match(a, b, 0.01) - - def test_different_shapes_do_not_match(self): - a = _polyline(SQUARE, close=True) - b = _polyline([(0, 0), (10, 0), (10, 11), (0, 10)], close=True) - assert not geometries_match(a, b, 0.01) - - def test_extra_contour_does_not_match(self): - a = _polyline(SQUARE, close=True) - b = _concat( - _polyline(SQUARE, close=True), _polyline([(20, 0), (30, 0)]) - ) - assert not geometries_match(a, b, 0.01) - - def test_empty_geometries_do_not_match(self): - assert not geometries_match(Geometry(), Geometry(), 0.01) - - -class TestSplitContours: - def test_splits_every_contour_including_holes_and_open_paths(self): - inner = [(2, 2), (8, 2), (8, 8), (2, 8)] - geo = _concat( - _polyline(SQUARE, close=True), - _polyline(inner, close=True), - _polyline([(20, 0), (30, 0)]), - ) - parts = split_contours(geo) - assert len(parts) == 3 - assert parts[2].distance() == pytest.approx(10.0) - - def test_single_contour(self): - assert len(split_contours(_polyline(SQUARE, close=True))) == 1 From 27cbfbd158b4b90558e2523356b5b59682878354 Mon Sep 17 00:00:00 2001 From: Samuel Abels Date: Sun, 11 Oct 2026 00:50:08 +0200 Subject: [PATCH 033/104] refactor(image): back the dither algorithms with raygeo 1.62 The error-diffusion kernels, the serpentine scan and the halftone and newsprint screens now live in raygeo (barebaric/raygeo#55, released as 1.62.0) instead of being reimplemented here. rayforge/image/dither keeps the DitherAlgorithm catalogue and dispatches to raygeo, translating the enum values to raygeo's kernel names; the Python-side kernels, spot function and screen builders are gone. Bump the raygeo pin to ==1.62.0 in pixi.toml, requirements.txt and debian/requirements-bundle.txt, and relock pixi.lock. --- debian/requirements-bundle.txt | 2 +- pixi.lock | 109 ++++----- pixi.toml | 2 +- rayforge/image/dither.py | 308 ++------------------------ requirements.txt | 2 +- tests/image/test_dither_algorithms.py | 162 +++----------- 6 files changed, 115 insertions(+), 470 deletions(-) diff --git a/debian/requirements-bundle.txt b/debian/requirements-bundle.txt index e9fe1a1ef..0d43efc59 100644 --- a/debian/requirements-bundle.txt +++ b/debian/requirements-bundle.txt @@ -1,7 +1,7 @@ asyncudp==0.11.0 pyvips==3.2.0 raydriver==0.3.0 -raygeo==1.61.0 +raygeo==1.62.0 ruida-pa==0.21.2 trimesh==5.1.1 vtracer==0.6.15 \ No newline at end of file diff --git a/pixi.lock b/pixi.lock index ed6bb2c56..7390f5939 100644 --- a/pixi.lock +++ b/pixi.lock @@ -1,7 +1,16 @@ version: 7 platforms: - name: linux-64 + virtual-packages: + - __unix=0=0 + - __linux=4.18 + - __glibc=2.28 + - __archspec=0=x86_64 - name: osx-arm64 + virtual-packages: + - __unix=0=0 + - __osx=13.0 + - __archspec=0=m1 environments: appimage: channels: @@ -228,7 +237,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/sysroot_linux-64-2.28-h4ee821c_9.conda - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - - pypi: . + - pypi: ./ - pypi: https://files.pythonhosted.org/packages/03/43/344fbf03470b38c11c334fd1ea9f12212036c93f64887d1f31672a95fd8d/ruida_pa-0.22.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/03/c0/a6d8be9c43e4456fb9597fdf8b5e0ce1f0a5df41503acce6d869536e4e23/websockets-17.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl @@ -242,6 +251,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3a/cf/9c2e4d6c179529d5bf4a64cff76fa581486569e9fbdd35bd98f51cb624bf/msgpack-1.2.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/3c/2c/c43c03eaf630435f023f1dc61ec4a4a78951ad5530a62c71cc89bde307b7/pypdf-6.19.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/3d/de/02f631997741485881fbe129aef2de75a23b4e9776f5b1c4a7afc313ae7e/raygeo-1.62.0.tar.gz - pypi: https://files.pythonhosted.org/packages/3f/99/2e119d541d596daea39643eb9312b47c7847383951300f889166938035b1/rpyc-6.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/41/51/8041c2e70649e5b7f2a0aedbbbd0609ac099cfaa0cbde2014279c9c05756/cairosvg-2.9.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/4f/a7/aeedb5140afa41fc74c225e9184ab96723a6e873b6ee1c9fede7283456d8/markupsafe-3.0.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl @@ -280,7 +290,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/c5/9b/6ce1ead737fe496611bda600c263b9a11ae7bd8f41bb13f9bd7e0a2c37a4/platformdirs-4.12.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c7/06/dace3e27af26690cb20bead80dbac42941b0841eb689b8aabbd67dde16f0/pymupdf-1.28.2-cp310-abi3-manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/cf/4c/c73f828fdbcd37eaf21d08fa852544a3ca7c2dbb3ea76873d64f2ea413d1/opencv_python-5.0.0.93-cp37-abi3-manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/dd/e9/fe7809075e9b29f9d938950eefe7a3da84d0f69be540d6dc1aa5a25ef02e/raygeo-1.61.0.tar.gz - pypi: https://files.pythonhosted.org/packages/df/f3/90993aab504fa2e1f28fcc09aa16b6ea4f00e75a037d9136e737855833e2/pyvips-3.2.0.tar.gz - pypi: https://files.pythonhosted.org/packages/e7/29/2094fd7719146ca7bb236be9751b5929d431f643cbab669aed89cf121883/ezdxf-1.4.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/ed/5b/db08419c1e1f7c9d60cfd2787b2b517d7ae4ebbda8281b48a33eb5141467/multidict-6.9.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl @@ -461,7 +470,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/xorg-libxt-1.3.1-hb001647_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zlib-1.3.2-h8088a28_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hf451053_7.conda - - pypi: . + - pypi: ./ - pypi: https://files.pythonhosted.org/packages/03/43/344fbf03470b38c11c334fd1ea9f12212036c93f64887d1f31672a95fd8d/ruida_pa-0.22.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl @@ -471,11 +480,11 @@ environments: - pypi: https://files.pythonhosted.org/packages/13/d4/1152d1c7ab42d8b908be64fd200ddc870dc9d4925e951198702084aa1a7d/linkify_it_py-2.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/16/67/1f0b582e73ce46511f2799cbdcdbdf9ca98f344f2aad42ff5ff5dde0d8fe/narwhals-2.27.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/17/fe/77f4a24264e728fd95542e53f026a91249b51d1611087b8c82d3a033ea97/asyncudp-0.11.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/20/a5/1d1baffeb743cbdfff98c180473a9c81bbabe433dea5a72761d4c1edf1ef/raygeo-1.62.0-cp311-abi3-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3c/2c/c43c03eaf630435f023f1dc61ec4a4a78951ad5530a62c71cc89bde307b7/pypdf-6.19.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3f/99/2e119d541d596daea39643eb9312b47c7847383951300f889166938035b1/rpyc-6.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/41/51/8041c2e70649e5b7f2a0aedbbbd0609ac099cfaa0cbde2014279c9c05756/cairosvg-2.9.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/4b/5c/7bb93dd4c4c755c5bf99e9ffadeb4ec8a6477fd229994fdcbc74e25a76b7/raygeo-1.61.0-cp311-abi3-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5c/09/1abf942723c0001d9c2fca1551907dade6304517b982b0bf10bba107fa81/websockets-17.2-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/60/45/c7b5c3168458db837e8ceab06dc77824e18202679d0463f0e8f002143a97/tinycss2-1.5.1-py3-none-any.whl @@ -906,7 +915,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - - pypi: . + - pypi: ./ - pypi: https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/03/43/344fbf03470b38c11c334fd1ea9f12212036c93f64887d1f31672a95fd8d/ruida_pa-0.22.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/03/c0/a6d8be9c43e4456fb9597fdf8b5e0ce1f0a5df41503acce6d869536e4e23/websockets-17.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl @@ -924,6 +933,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3a/cf/9c2e4d6c179529d5bf4a64cff76fa581486569e9fbdd35bd98f51cb624bf/msgpack-1.2.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/3c/2c/c43c03eaf630435f023f1dc61ec4a4a78951ad5530a62c71cc89bde307b7/pypdf-6.19.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/3d/de/02f631997741485881fbe129aef2de75a23b4e9776f5b1c4a7afc313ae7e/raygeo-1.62.0.tar.gz - pypi: https://files.pythonhosted.org/packages/3f/99/2e119d541d596daea39643eb9312b47c7847383951300f889166938035b1/rpyc-6.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/41/51/8041c2e70649e5b7f2a0aedbbbd0609ac099cfaa0cbde2014279c9c05756/cairosvg-2.9.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/45/e2/bbb7129c9e7999a6b8ee9cca3b66486c25c423ab5a75f34071798b74ce94/pre_commit-4.6.2-py2.py3-none-any.whl @@ -974,7 +984,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/d7/ba/18b6e682ead424ad24bcc134339ae5d1b931cd9ae260540592a058a91279/pyright-1.1.414-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/db/5b/b83a9bf1a3b4ec222f9fa083147ff6816245223da0ab92370e7e056f113f/pytest_mock-3.16.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/dd/e9/fe7809075e9b29f9d938950eefe7a3da84d0f69be540d6dc1aa5a25ef02e/raygeo-1.61.0.tar.gz - pypi: https://files.pythonhosted.org/packages/df/93/084f10ebbecdedf7902ed68fbecc903f0e9c5742155bfffb05eccbc071da/virtualenv-21.14.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/df/f3/90993aab504fa2e1f28fcc09aa16b6ea4f00e75a037d9136e737855833e2/pyvips-3.2.0.tar.gz - pypi: https://files.pythonhosted.org/packages/e4/03/17234145f302a645a123e8c3bb2411ecf4669fbc350de3b0d803230a1729/ruff-0.16.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl @@ -1166,7 +1175,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/xorg-libxt-1.3.1-hb001647_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zlib-1.3.2-h8088a28_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hf451053_7.conda - - pypi: . + - pypi: ./ - pypi: https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/03/43/344fbf03470b38c11c334fd1ea9f12212036c93f64887d1f31672a95fd8d/ruida_pa-0.22.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl @@ -1179,6 +1188,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/16/67/1f0b582e73ce46511f2799cbdcdbdf9ca98f344f2aad42ff5ff5dde0d8fe/narwhals-2.27.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/16/7d/e9ffbadfbf89c93848412d04594135c4ae8c1d37d9e053b9c3ed718fabc4/python_discovery-1.6.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/17/fe/77f4a24264e728fd95542e53f026a91249b51d1611087b8c82d3a033ea97/asyncudp-0.11.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/20/a5/1d1baffeb743cbdfff98c180473a9c81bbabe433dea5a72761d4c1edf1ef/raygeo-1.62.0-cp311-abi3-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3c/2c/c43c03eaf630435f023f1dc61ec4a4a78951ad5530a62c71cc89bde307b7/pypdf-6.19.0-py3-none-any.whl @@ -1186,7 +1196,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/41/51/8041c2e70649e5b7f2a0aedbbbd0609ac099cfaa0cbde2014279c9c05756/cairosvg-2.9.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/45/e2/bbb7129c9e7999a6b8ee9cca3b66486c25c423ab5a75f34071798b74ce94/pre_commit-4.6.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/4a/4d/2983a9fc0a412a09151299d1ec9eb7511e3d577f7213e19395fabebf9db6/filelock-4.0.11-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/4b/5c/7bb93dd4c4c755c5bf99e9ffadeb4ec8a6477fd229994fdcbc74e25a76b7/raygeo-1.61.0-cp311-abi3-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/54/c8/12811c9b48fde162bb72b6f2e78fada9a247a09d7bb5be2050a5d099c77b/nodeenv-1.11.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/56/e2/480bcdb153feecde4d736b18c908c7eee4846f11d7547d2e0c71499af630/pycodestyle-2.15.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl @@ -9634,11 +9643,11 @@ packages: - zstd >=1.5.7,<1.6.0a0 size: 433687 timestamp: 1786599629846 -- pypi: . +- pypi: ./ name: rayforge requires_dist: - raydriver==0.3.0 - - raygeo==1.61.0 + - raygeo==1.62.0 - aiohttp==3.14.3 - asyncudp==0.11.0 - blinker==1.9.0 @@ -9811,6 +9820,26 @@ packages: name: asyncudp version: 0.11.0 sha256: 96d859d86471e3ed30cf9f05cb38e96920156e3a9fe70ee916096ba758ea316e +- pypi: https://files.pythonhosted.org/packages/20/a5/1d1baffeb743cbdfff98c180473a9c81bbabe433dea5a72761d4c1edf1ef/raygeo-1.62.0-cp311-abi3-macosx_11_0_arm64.whl + name: raygeo + version: 1.62.0 + sha256: 094187c356b390e729a8fa7287b97f7f657b26a280cf2014c6726e944786a4a3 + requires_dist: + - msgpack + - numpy>=1.20.0 + - matplotlib ; extra == 'cli' + - matplotlib ; extra == 'docs' + - mdformat ; extra == 'docs' + - mdformat-frontmatter ; extra == 'docs' + - mdformat-tables ; extra == 'docs' + - pillow ; extra == 'docs' + - pycairo ; extra == 'test' + - pytest ; extra == 'test' + - pytest-mock ; extra == 'test' + - streamlit ; extra == 'test' + - streamlit ; extra == 'visual' + - matplotlib ; extra == 'visual' + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl name: mccabe version: 0.7.0 @@ -9865,6 +9894,26 @@ packages: - arabic-reshaper ; extra == 'rtl-text' - python-bidi ; extra == 'rtl-text' requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/3d/de/02f631997741485881fbe129aef2de75a23b4e9776f5b1c4a7afc313ae7e/raygeo-1.62.0.tar.gz + name: raygeo + version: 1.62.0 + sha256: 45b319ba805362aaa288532a0f0b0394b0898f39724c9b6a7ae378c463ed2346 + requires_dist: + - msgpack + - numpy>=1.20.0 + - pycairo ; extra == 'test' + - pytest ; extra == 'test' + - pytest-mock ; extra == 'test' + - streamlit ; extra == 'test' + - streamlit ; extra == 'visual' + - matplotlib ; extra == 'visual' + - matplotlib ; extra == 'docs' + - mdformat ; extra == 'docs' + - mdformat-frontmatter ; extra == 'docs' + - mdformat-tables ; extra == 'docs' + - pillow ; extra == 'docs' + - matplotlib ; extra == 'cli' + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/3f/99/2e119d541d596daea39643eb9312b47c7847383951300f889166938035b1/rpyc-6.0.2-py3-none-any.whl name: rpyc version: 6.0.2 @@ -9904,26 +9953,6 @@ packages: version: 4.0.11 sha256: 1cbdc4ace7fb11d2f6d073f5c3d91537a23b3d58d2bf2fdffc12c6d717a589c6 requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/4b/5c/7bb93dd4c4c755c5bf99e9ffadeb4ec8a6477fd229994fdcbc74e25a76b7/raygeo-1.61.0-cp311-abi3-macosx_11_0_arm64.whl - name: raygeo - version: 1.61.0 - sha256: 69f0683f4405c7fecef3cccc7ff07f5a0375a3b587a4893b609c1ee0cb8de67f - requires_dist: - - msgpack - - numpy>=1.20.0 - - matplotlib ; extra == 'cli' - - matplotlib ; extra == 'docs' - - mdformat ; extra == 'docs' - - mdformat-frontmatter ; extra == 'docs' - - mdformat-tables ; extra == 'docs' - - pillow ; extra == 'docs' - - pycairo ; extra == 'test' - - pytest ; extra == 'test' - - pytest-mock ; extra == 'test' - - streamlit ; extra == 'test' - - streamlit ; extra == 'visual' - - matplotlib ; extra == 'visual' - requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/4f/a7/aeedb5140afa41fc74c225e9184ab96723a6e873b6ee1c9fede7283456d8/markupsafe-3.0.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl name: markupsafe version: 3.0.4 @@ -10619,26 +10648,6 @@ packages: - pytest-asyncio ; extra == 'dev' - tox ; extra == 'dev' requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/dd/e9/fe7809075e9b29f9d938950eefe7a3da84d0f69be540d6dc1aa5a25ef02e/raygeo-1.61.0.tar.gz - name: raygeo - version: 1.61.0 - sha256: e28cbf80230f18001cb8955240176e6a0c3a0070915cffd6a6e2f9509d2cc694 - requires_dist: - - msgpack - - numpy>=1.20.0 - - pycairo ; extra == 'test' - - pytest ; extra == 'test' - - pytest-mock ; extra == 'test' - - streamlit ; extra == 'test' - - streamlit ; extra == 'visual' - - matplotlib ; extra == 'visual' - - matplotlib ; extra == 'docs' - - mdformat ; extra == 'docs' - - mdformat-frontmatter ; extra == 'docs' - - mdformat-tables ; extra == 'docs' - - pillow ; extra == 'docs' - - matplotlib ; extra == 'cli' - requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/df/93/084f10ebbecdedf7902ed68fbecc903f0e9c5742155bfffb05eccbc071da/virtualenv-21.14.5-py3-none-any.whl name: virtualenv version: 21.14.5 diff --git a/pixi.toml b/pixi.toml index f35a20b38..b9c8738b2 100644 --- a/pixi.toml +++ b/pixi.toml @@ -38,7 +38,7 @@ rust = "==1.98.1" patchelf = "==0.19.2" [feature.app.pypi-dependencies] -raygeo = "==1.61.0" +raygeo = "==1.62.0" raydriver = "==0.3.0" rayforge = { editable = true, path = "." } ruida-pa = "==0.22.0" diff --git a/rayforge/image/dither.py b/rayforge/image/dither.py index 83897fb08..63cadf37e 100644 --- a/rayforge/image/dither.py +++ b/rayforge/image/dither.py @@ -1,6 +1,5 @@ """Dithering algorithms for converting grayscale images to binary.""" -import math from enum import Enum from gettext import gettext as _ @@ -8,18 +7,16 @@ from raygeo.image.convert import rgba_to_grayscale from raygeo.image.dither import ( apply_bayer_dither, + apply_error_diffusion_dither, apply_floyd_steinberg_dither, + apply_halftone_dither, apply_minimum_run_length, + apply_newsprint_dither, ) -from raygeo.image.srgb import srgb_to_linear DEFAULT_HALFTONE_CELL_MM = 0.5 DEFAULT_HALFTONE_ANGLE = 45.0 -#: Rows processed per block when building the halftone screen, to -#: bound the memory of the per-pixel coordinate arrays. -_HALFTONE_BLOCK_ROWS = 256 - class DitherAlgorithm(Enum): FLOYD_STEINBERG = "floyd_steinberg" @@ -57,97 +54,21 @@ def display_name(self) -> str: @property def is_error_diffusion(self) -> bool: - return self in ERROR_DIFFUSION_KERNELS - - -#: Error-diffusion kernels as ``(divisor, [(dx, dy, weight), ...])``. -#: ``dx`` is relative to the scan direction, so serpentine rows mirror -#: it. Atkinson deliberately diffuses only 6/8 of the error. -ERROR_DIFFUSION_KERNELS: dict[ - DitherAlgorithm, tuple[int, list[tuple[int, int, int]]] -] = { - DitherAlgorithm.FLOYD_STEINBERG: ( - 16, - [(1, 0, 7), (-1, 1, 3), (0, 1, 5), (1, 1, 1)], - ), - DitherAlgorithm.ATKINSON: ( - 8, - [(1, 0, 1), (2, 0, 1), (-1, 1, 1), (0, 1, 1), (1, 1, 1), (0, 2, 1)], - ), - DitherAlgorithm.STUCKI: ( - 42, - [ - (1, 0, 8), - (2, 0, 4), - (-2, 1, 2), - (-1, 1, 4), - (0, 1, 8), - (1, 1, 4), - (2, 1, 2), - (-2, 2, 1), - (-1, 2, 2), - (0, 2, 4), - (1, 2, 2), - (2, 2, 1), - ], - ), - DitherAlgorithm.JARVIS_JUDICE_NINKE: ( - 48, - [ - (1, 0, 7), - (2, 0, 5), - (-2, 1, 3), - (-1, 1, 5), - (0, 1, 7), - (1, 1, 5), - (2, 1, 3), - (-2, 2, 1), - (-1, 2, 3), - (0, 2, 5), - (1, 2, 3), - (2, 2, 1), - ], - ), - DitherAlgorithm.SIERRA: ( - 32, - [ - (1, 0, 5), - (2, 0, 3), - (-2, 1, 2), - (-1, 1, 4), - (0, 1, 5), - (1, 1, 4), - (2, 1, 2), - (-1, 2, 2), - (0, 2, 3), - (1, 2, 2), - ], - ), - DitherAlgorithm.SIERRA_2ROW: ( - 16, - [ - (1, 0, 4), - (2, 0, 3), - (-2, 1, 1), - (-1, 1, 2), - (0, 1, 3), - (1, 1, 2), - (2, 1, 1), - ], - ), - DitherAlgorithm.SIERRA_LITE: (4, [(1, 0, 2), (-1, 1, 1), (0, 1, 1)]), - DitherAlgorithm.BURKES: ( - 32, - [ - (1, 0, 8), - (2, 0, 4), - (-2, 1, 2), - (-1, 1, 4), - (0, 1, 8), - (1, 1, 4), - (2, 1, 2), - ], - ), + return self in KERNEL_NAMES + + +#: Raygeo kernel names of the error-diffusion algorithms. The Jarvis, +#: Judice & Ninke kernel is spelled out in raygeo while the enum keeps +#: its shorter historical value. +KERNEL_NAMES: dict[DitherAlgorithm, str] = { + DitherAlgorithm.FLOYD_STEINBERG: "floyd_steinberg", + DitherAlgorithm.ATKINSON: "atkinson", + DitherAlgorithm.STUCKI: "stucki", + DitherAlgorithm.JARVIS_JUDICE_NINKE: "jarvis_judice_ninke", + DitherAlgorithm.SIERRA: "sierra", + DitherAlgorithm.SIERRA_2ROW: "sierra_2row", + DitherAlgorithm.SIERRA_LITE: "sierra_lite", + DitherAlgorithm.BURKES: "burkes", } @@ -173,191 +94,6 @@ def is_error_diffusion(self) -> bool: } -def _spot_function(u: np.ndarray, v: np.ndarray) -> np.ndarray: - """Round-dot screen spot function in cell units, 1 at dot centres - and 0 halfway between them.""" - return (np.cos(2.0 * np.pi * u) + np.cos(2.0 * np.pi * v)) / 4.0 + 0.5 - - -def _build_spot_cdf(samples: int = 256) -> np.ndarray: - """Sorted spot values over one cell, used to turn a spot value - into the fraction of the cell it covers.""" - grid = (np.arange(samples) + 0.5) / samples - u, v = np.meshgrid(grid, grid) - return np.sort(_spot_function(u, v).ravel()) - - -def _build_newsprint_matrix(size: int = 8) -> np.ndarray: - """Clustered-dot threshold ranks with the dots on a 45° lattice.""" - grid = (np.arange(size) + 0.5) / size - u, v = np.meshgrid(grid, grid) - spot = _spot_function(u + v, u - v) - order = np.argsort(-spot.ravel(), kind="stable") - ranks = np.empty(size * size, dtype=np.int32) - ranks[order] = np.arange(size * size) - return ranks.reshape(size, size) - - -_SPOT_CDF = _build_spot_cdf() -NEWSPRINT_MATRIX = _build_newsprint_matrix() - - -def _diffuse_row( - row: np.ndarray, - forward: tuple[float, float], - reverse: bool, -) -> tuple[np.ndarray, np.ndarray]: - """Quantise one row, pushing the error to the next one or two - pixels in scan direction. Returns ``(errors, dark)`` in image - order.""" - values = (row[::-1] if reverse else row).tolist() - width = len(values) - values.extend((0.0, 0.0)) - w1, w2 = forward - errors = [0.0] * width - dark = [0] * width - for x in range(width): - value = values[x] - if value < 0.5: - dark[x] = 1 - error = value - else: - error = value - 1.0 - errors[x] = error - values[x + 1] += error * w1 - values[x + 2] += error * w2 - errors_arr = np.asarray(errors) - dark_arr = np.asarray(dark, dtype=np.uint8) - if reverse: - return errors_arr[::-1], dark_arr[::-1] - return errors_arr, dark_arr - - -def apply_error_diffusion( - values: np.ndarray, - kernel: tuple[int, list[tuple[int, int, int]]], - serpentine: bool = False, -) -> np.ndarray: - """ - Error-diffusion dithering with an arbitrary forward kernel. - - The error within a row is propagated pixel by pixel; the error for - the following rows is spread with one vectorised add per kernel - tap once the row is done. - - Args: - values: 2D float array of intensities in [0, 1] (1 = white). - kernel: ``(divisor, [(dx, dy, weight), ...])`` with - ``dy == 0`` taps limited to ``dx`` 1 and 2. - serpentine: Scan odd rows right-to-left, mirroring the kernel. - - Returns: - Binary uint8 array where 1 marks pixels to engrave. - """ - height, width = values.shape - out = np.zeros((height, width), dtype=np.uint8) - if height == 0 or width == 0: - return out - divisor, taps = kernel - forward = [0.0, 0.0, 0.0] - later = [] - for dx, dy, weight in taps: - if dy == 0: - forward[dx] = weight / divisor - else: - later.append((dx, dy, weight / divisor)) - pad = max((abs(dx) for dx, _dy, _w in later), default=0) - extra_rows = max((dy for _dx, dy, _w in later), default=0) - work = np.zeros((height + extra_rows, width + 2 * pad)) - work[:height, pad : pad + width] = values - for y in range(height): - reverse = serpentine and y % 2 == 1 - errors, dark = _diffuse_row( - work[y, pad : pad + width], (forward[1], forward[2]), reverse - ) - out[y] = dark - for dx, dy, weight in later: - start = pad + (-dx if reverse else dx) - work[y + dy, start : start + width] += errors * weight - return out - - -def apply_halftone( - grayscale: np.ndarray, - cell_mm: float, - angle_deg: float, - pixels_per_mm: tuple[float, float], -) -> np.ndarray: - """ - Amplitude-modulated halftone screen with round dots. - - The screen is laid out in millimetres, so the dots stay round on - non-square pixels. Dot area tracks darkness: a pixel is engraved - when its brightness is below the share of the cell that the spot - function ranks at or under it. - - Args: - grayscale: 2D uint8 grayscale array (0-255). - cell_mm: Distance between dot centres in mm. - angle_deg: Screen angle in degrees. - pixels_per_mm: ``(x, y)`` image resolution. - - Returns: - Binary uint8 array where 1 marks pixels to engrave. - """ - height, width = grayscale.shape - ppm_x, ppm_y = pixels_per_mm - if cell_mm <= 0: - cell_mm = 1.0 / max(ppm_x, ppm_y) - theta = math.radians(angle_deg) - cos_t = math.cos(theta) / cell_mm - sin_t = math.sin(theta) / cell_mm - x_mm = (np.arange(width) + 0.5) / ppm_x - out = np.empty((height, width), dtype=np.uint8) - for top in range(0, height, _HALFTONE_BLOCK_ROWS): - bottom = min(height, top + _HALFTONE_BLOCK_ROWS) - y_mm = ((np.arange(top, bottom) + 0.5) / ppm_y)[:, None] - u = x_mm * cos_t + y_mm * sin_t - v = y_mm * cos_t - x_mm * sin_t - spot = _spot_function(u, v) - coverage = (np.searchsorted(_SPOT_CDF, spot) + 0.5) / _SPOT_CDF.size - brightness = grayscale[top:bottom] / 255.0 - out[top:bottom] = brightness < coverage - return out - - -def _apply_newsprint( - grayscale: np.ndarray, - min_feature_px: int, - pixels_per_mm: tuple[float, float], -) -> np.ndarray: - """Tile the clustered-dot matrix with physically square cells.""" - height, width = grayscale.shape - size = NEWSPRINT_MATRIX.shape[0] - ppm_x, ppm_y = pixels_per_mm - cell_x = max(1, min_feature_px) - cell_y = max(1, round(cell_x * ppm_y / ppm_x)) - rows = (np.arange(height) // cell_y) % size - cols = (np.arange(width) // cell_x) % size - ranks = NEWSPRINT_MATRIX[rows[:, None], cols[None, :]] - thresholds = 255.0 * (1.0 - (ranks + 0.5) / (size * size)) - return (grayscale < thresholds).astype(np.uint8) - - -def _error_diffusion_dither( - grayscale: np.ndarray, - dither_algorithm: DitherAlgorithm, - serpentine: bool, -) -> np.ndarray: - """Dither in linear light, like raygeo's Floyd-Steinberg.""" - linear = srgb_to_linear(np.ascontiguousarray(grayscale, dtype=np.uint8)) - return apply_error_diffusion( - linear.astype(np.float64), - ERROR_DIFFUSION_KERNELS[dither_algorithm], - serpentine, - ) - - def grayscale_to_dithered_array( grayscale: np.ndarray, dither_algorithm: DitherAlgorithm, @@ -400,12 +136,14 @@ def grayscale_to_dithered_array( if invert: grayscale = 255 - grayscale if dither_algorithm == DitherAlgorithm.HALFTONE: - return apply_halftone( + return apply_halftone_dither( grayscale, halftone_cell_mm, halftone_angle, pixels_per_mm ) if dither_algorithm == DitherAlgorithm.NEWSPRINT: - return _apply_newsprint(grayscale, min_feature_px, pixels_per_mm) - bw_image = _error_diffusion_dither(grayscale, dither_algorithm, serpentine) + return apply_newsprint_dither(grayscale, min_feature_px, pixels_per_mm) + bw_image = apply_error_diffusion_dither( + grayscale, KERNEL_NAMES[dither_algorithm], serpentine=serpentine + ) return apply_minimum_run_length(bw_image, min_feature_px) diff --git a/requirements.txt b/requirements.txt index 5c03767b4..b46963b07 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ raydriver==0.3.0 -raygeo==1.61.0 +raygeo==1.62.0 aiohttp==3.14.3 asyncudp==0.11.0 blinker==1.9.0 diff --git a/tests/image/test_dither_algorithms.py b/tests/image/test_dither_algorithms.py index 73991d0c8..6f426175d 100644 --- a/tests/image/test_dither_algorithms.py +++ b/tests/image/test_dither_algorithms.py @@ -6,18 +6,13 @@ import pytest from raygeo.image.dither import ( apply_floyd_steinberg_dither, + apply_halftone_dither, apply_minimum_run_length, ) +from raygeo.image.srgb import srgb_to_linear from scipy import ndimage -from rayforge.image.dither import ( - ERROR_DIFFUSION_KERNELS, - DitherAlgorithm, - apply_error_diffusion, - apply_halftone, - grayscale_to_dithered_array, - srgb_to_linear, -) +from rayforge.image.dither import DitherAlgorithm, grayscale_to_dithered_array ERROR_DIFFUSION = [ DitherAlgorithm.FLOYD_STEINBERG, @@ -43,38 +38,11 @@ ] -def _reference_diffusion(values, kernel, serpentine): - """Straightforward per-pixel error diffusion used as an oracle.""" - divisor, taps = kernel - work = values.astype(np.float64).copy() - height, width = work.shape - out = np.zeros((height, width), dtype=np.uint8) - for y in range(height): - reverse = serpentine and y % 2 == 1 - xs = range(width - 1, -1, -1) if reverse else range(width) - for x in xs: - old = work[y, x] - new = 0.0 if old < 0.5 else 1.0 - out[y, x] = 1 if new == 0.0 else 0 - err = old - new - for dx, dy, weight in taps: - tx = x - dx if reverse else x + dx - ty = y + dy - if 0 <= tx < width and ty < height: - work[ty, tx] += err * weight / divisor - return out - - def _label(image: np.ndarray) -> tuple[np.ndarray, int]: """Connected dots of a binary image and their count.""" return cast(tuple[np.ndarray, int], ndimage.label(image)) -def _dyadic_image(rng, shape): - """Values k/16 keep every diffused error exactly representable.""" - return rng.integers(0, 17, shape).astype(np.float64) / 16.0 - - class TestAlgorithmCatalogue: def test_new_algorithms_exist_with_stable_values(self): assert DitherAlgorithm.ATKINSON.value == "atkinson" @@ -95,74 +63,6 @@ def test_error_diffusion_flag(self): for algo in DitherAlgorithm: assert algo.is_error_diffusion == (algo in ERROR_DIFFUSION) - @pytest.mark.parametrize("algo", ERROR_DIFFUSION) - def test_kernel_weights_sum_to_divisor(self, algo): - divisor, taps = ERROR_DIFFUSION_KERNELS[algo] - total = sum(weight for _dx, _dy, weight in taps) - if algo is DitherAlgorithm.ATKINSON: - assert total == 6 and divisor == 8 - else: - assert total == divisor - - @pytest.mark.parametrize("algo", ERROR_DIFFUSION) - def test_kernels_only_push_error_forward(self, algo): - _divisor, taps = ERROR_DIFFUSION_KERNELS[algo] - for dx, dy, _weight in taps: - assert dy > 0 or (dy == 0 and dx > 0) - - def test_stucki_kernel(self): - divisor, taps = ERROR_DIFFUSION_KERNELS[DitherAlgorithm.STUCKI] - assert divisor == 42 - assert sorted(taps) == sorted( - [ - (1, 0, 8), - (2, 0, 4), - (-2, 1, 2), - (-1, 1, 4), - (0, 1, 8), - (1, 1, 4), - (2, 1, 2), - (-2, 2, 1), - (-1, 2, 2), - (0, 2, 4), - (1, 2, 2), - (2, 2, 1), - ] - ) - - -class TestErrorDiffusionCore: - @pytest.mark.parametrize("algo", ERROR_DIFFUSION) - @pytest.mark.parametrize("serpentine", [False, True]) - def test_matches_reference_implementation(self, algo, serpentine): - rng = np.random.default_rng(7) - kernel = ERROR_DIFFUSION_KERNELS[algo] - for shape in [(1, 1), (1, 6), (5, 1), (4, 6), (6, 5)]: - values = _dyadic_image(rng, shape) - expected = _reference_diffusion(values, kernel, serpentine) - result = apply_error_diffusion(values, kernel, serpentine) - np.testing.assert_array_equal(result, expected) - - def test_serpentine_reverses_odd_rows(self): - kernel = ERROR_DIFFUSION_KERNELS[DitherAlgorithm.SIERRA_LITE] - values = np.tile(np.linspace(0.0, 1.0, 8), (4, 1)) - plain = apply_error_diffusion(values, kernel, False) - serpentine = apply_error_diffusion(values, kernel, True) - np.testing.assert_array_equal(plain[:2], serpentine[:2]) - assert not np.array_equal(plain, serpentine) - - def test_does_not_modify_input(self): - kernel = ERROR_DIFFUSION_KERNELS[DitherAlgorithm.STUCKI] - values = np.full((5, 5), 0.3) - original = values.copy() - apply_error_diffusion(values, kernel, True) - np.testing.assert_array_equal(values, original) - - def test_empty_image(self): - kernel = ERROR_DIFFUSION_KERNELS[DitherAlgorithm.STUCKI] - result = apply_error_diffusion(np.zeros((0, 4)), kernel, False) - assert result.shape == (0, 4) - class TestGrayscaleToDitheredArray: @pytest.mark.parametrize("algo", NEW_ALGORITHMS) @@ -239,6 +139,13 @@ def test_floyd_steinberg_serpentine_differs_from_raygeo(self): assert not np.array_equal(plain, serpentine) assert serpentine.mean() == pytest.approx(plain.mean(), abs=0.02) + def test_enum_values_translate_to_raygeo_kernels(self): + """The enum values are not raygeo kernel names (Jarvis, Judice + & Ninke in particular); the dispatch must translate them.""" + img = np.full((16, 16), 100, dtype=np.uint8) + for algo in ERROR_DIFFUSION: + assert grayscale_to_dithered_array(img, algo).shape == (16, 16) + @pytest.mark.parametrize("algo", ERROR_DIFFUSION[1:]) def test_minimum_feature_size_removes_short_runs(self, algo): img = np.full((32, 64), 200, dtype=np.uint8) @@ -259,12 +166,19 @@ def test_serpentine_is_ignored_by_screens(self): class TestHalftone: + def _halftone(self, img, cell_mm, angle_deg, pixels_per_mm): + return grayscale_to_dithered_array( + img, + DitherAlgorithm.HALFTONE, + halftone_cell_mm=cell_mm, + halftone_angle=angle_deg, + pixels_per_mm=pixels_per_mm, + ) + @pytest.mark.parametrize("gray", [30, 100, 160, 220]) def test_coverage_follows_darkness(self, gray): img = np.full((200, 200), gray, dtype=np.uint8) - result = apply_halftone( - img, cell_mm=1.0, angle_deg=45.0, pixels_per_mm=(10.0, 10.0) - ) + result = self._halftone(img, 1.0, 45.0, (10.0, 10.0)) assert result.mean() == pytest.approx(1.0 - gray / 255.0, abs=0.03) def test_dot_count_follows_cell_size(self): @@ -272,12 +186,7 @@ def test_dot_count_follows_cell_size(self): a 20 mm square holds 21 x 21 (partly clipped) dots at 1 mm.""" img = np.full((200, 200), 230, dtype=np.uint8) for cell_mm, expected in ((1.0, 441), (2.0, 121)): - result = apply_halftone( - img, - cell_mm=cell_mm, - angle_deg=0.0, - pixels_per_mm=(10.0, 10.0), - ) + result = self._halftone(img, cell_mm, 0.0, (10.0, 10.0)) _labels, count = _label(result) assert count == pytest.approx(expected, rel=0.1) @@ -285,9 +194,7 @@ def test_dots_are_round_on_anisotropic_pixels(self): """X is sampled twice as densely as Y; the dots must still be round in millimetres, so twice as wide in pixels.""" img = np.full((100, 200), 220, dtype=np.uint8) - result = apply_halftone( - img, cell_mm=2.0, angle_deg=0.0, pixels_per_mm=(20.0, 10.0) - ) + result = self._halftone(img, 2.0, 0.0, (20.0, 10.0)) labels, _count = _label(result) boxes = ndimage.find_objects(labels) interior = [ @@ -306,32 +213,23 @@ def test_dots_are_round_on_anisotropic_pixels(self): def test_angle_rotates_the_screen(self): img = np.full((120, 120), 200, dtype=np.uint8) - kwargs = {"cell_mm": 1.0, "pixels_per_mm": (10.0, 10.0)} - straight = apply_halftone(img, angle_deg=0.0, **kwargs) - rotated = apply_halftone(img, angle_deg=45.0, **kwargs) + straight = self._halftone(img, 1.0, 0.0, (10.0, 10.0)) + rotated = self._halftone(img, 1.0, 45.0, (10.0, 10.0)) assert not np.array_equal(straight, rotated) assert rotated.mean() == pytest.approx(straight.mean(), abs=0.03) def test_invalid_cell_size_falls_back_to_one_pixel_cells(self): img = np.full((10, 10), 0, dtype=np.uint8) - result = apply_halftone( - img, cell_mm=0.0, angle_deg=0.0, pixels_per_mm=(10.0, 10.0) - ) + result = self._halftone(img, 0.0, 0.0, (10.0, 10.0)) assert result.all() - def test_dispatch_through_grayscale_to_dithered_array(self): + def test_dispatch_forwards_the_screen_settings_to_raygeo(self): img = np.full((100, 100), 128, dtype=np.uint8) - direct = apply_halftone( - img, cell_mm=0.8, angle_deg=30.0, pixels_per_mm=(10.0, 10.0) - ) - dispatched = grayscale_to_dithered_array( - img, - DitherAlgorithm.HALFTONE, - halftone_cell_mm=0.8, - halftone_angle=30.0, - pixels_per_mm=(10.0, 10.0), + dispatched = self._halftone(img, 0.8, 30.0, (10.0, 10.0)) + expected = apply_halftone_dither( + img, 0.8, 30.0, pixels_per_mm=(10.0, 10.0) ) - np.testing.assert_array_equal(direct, dispatched) + np.testing.assert_array_equal(dispatched, expected) class TestNewsprint: From 9bef11fae04eccee8e417b3254e4f0d75d069a31 Mon Sep 17 00:00:00 2001 From: medoix Date: Thu, 1 Oct 2026 11:52:04 +1000 Subject: [PATCH 034/104] fix(ruida): home X or Y individually Homing a single XY axis moved both X and Y to the origin, because any XY axis selection fell through to the combined home. Jog only the requested axis to 0 when X or Y is homed alone, and add jog_x_to and jog_y_to wrappers to the direct driver. Z is still homed with the auto-focus routine when included. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011Z6AnQL3k4XUquSTjcSeFb --- .../machine/driver/ruidarpa/rpa_adapter.py | 7 ++- .../driver/ruidarpa/rpa_direct_driver.py | 18 +++++++ tests/machine/driver/ruidarpa/test_adapter.py | 51 +++++++++++++++++++ .../driver/ruidarpa/test_direct_driver.py | 2 + 4 files changed, 77 insertions(+), 1 deletion(-) diff --git a/rayforge/machine/driver/ruidarpa/rpa_adapter.py b/rayforge/machine/driver/ruidarpa/rpa_adapter.py index c09d95462..f492cecbf 100644 --- a/rayforge/machine/driver/ruidarpa/rpa_adapter.py +++ b/rayforge/machine/driver/ruidarpa/rpa_adapter.py @@ -1250,8 +1250,13 @@ async def home(self, axes: Axis | None = None) -> None: # Live homing commands auto-send server-side (and the direct # backend auto-sends via the wrapped RdDriver); the returned # lines must not be run() again. - if axes is None or (axes & (Axis.X | Axis.Y)): + xy_axes = Axis.X | Axis.Y + if axes is None or (axes & xy_axes) == xy_axes: await loop.run_in_executor(None, self._backend.home) + elif axes & Axis.X: + await loop.run_in_executor(None, self._backend.jog_x_to, 0.0) + elif axes & Axis.Y: + await loop.run_in_executor(None, self._backend.jog_y_to, 0.0) if axes is not None and (axes & Axis.Z): await loop.run_in_executor(None, self._backend.focus_z) diff --git a/rayforge/machine/driver/ruidarpa/rpa_direct_driver.py b/rayforge/machine/driver/ruidarpa/rpa_direct_driver.py index 5f65b16a1..49ed1a73a 100644 --- a/rayforge/machine/driver/ruidarpa/rpa_direct_driver.py +++ b/rayforge/machine/driver/ruidarpa/rpa_direct_driver.py @@ -259,6 +259,24 @@ def jog_xy_to(self, x: float, y: float) -> None: """ self._require_connected().jog_xy_to(x, y) + def jog_x_to(self, x: float) -> None: + """Jog the X axis to an absolute position in mm. + + The wrapped RdDriver auto-sends the generated lines when + connected; the returned lines are deliberately discarded so each + jog is sent exactly once. + """ + self._require_connected().jog_x_to(x) + + def jog_y_to(self, y: float) -> None: + """Jog the Y axis to an absolute position in mm. + + The wrapped RdDriver auto-sends the generated lines when + connected; the returned lines are deliberately discarded so each + jog is sent exactly once. + """ + self._require_connected().jog_y_to(y) + def jog_xy_rel( self, x: float | None = None, y: float | None = None ) -> None: diff --git a/tests/machine/driver/ruidarpa/test_adapter.py b/tests/machine/driver/ruidarpa/test_adapter.py index 2f8b0c51b..94bf0cc4d 100644 --- a/tests/machine/driver/ruidarpa/test_adapter.py +++ b/tests/machine/driver/ruidarpa/test_adapter.py @@ -923,6 +923,57 @@ async def test_speed_only_jog_records_speed_without_backend( client.jog_xy_to.assert_called_once_with(0.0, 0.0) +class TestSingleAxisHome: + """Homing one XY axis moves only that axis to its origin.""" + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "adapter_pair", + [DIRECT_MODE, RPC_MODE], + ids=["direct", "rpc"], + indirect=True, + ) + async def test_home_x_moves_only_x(self, adapter_pair): + """home(Axis.X) must jog X to 0 and leave Y alone.""" + adapter, backend = adapter_pair + await adapter.home(Axis.X) + backend.jog_x_to.assert_called_once_with(0.0) + backend.jog_y_to.assert_not_called() + backend.home.assert_not_called() + backend.home_z.assert_not_called() + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "adapter_pair", + [DIRECT_MODE, RPC_MODE], + ids=["direct", "rpc"], + indirect=True, + ) + async def test_home_y_moves_only_y(self, adapter_pair): + """home(Axis.Y) must jog Y to 0 and leave X alone.""" + adapter, backend = adapter_pair + await adapter.home(Axis.Y) + backend.jog_y_to.assert_called_once_with(0.0) + backend.jog_x_to.assert_not_called() + backend.home.assert_not_called() + backend.home_z.assert_not_called() + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "adapter_pair", + [DIRECT_MODE, RPC_MODE], + ids=["direct", "rpc"], + indirect=True, + ) + async def test_home_x_and_z(self, adapter_pair): + """home(Axis.X | Axis.Z) homes Z and moves only X in the plane.""" + adapter, backend = adapter_pair + await adapter.home(Axis.X | Axis.Z) + backend.jog_x_to.assert_called_once_with(0.0) + backend.focus_z.assert_called_once() + backend.home.assert_not_called() + + class TestLiveBridgeDirect: """Direct live bridge delegates jog/home to the backend wrapper.""" diff --git a/tests/machine/driver/ruidarpa/test_direct_driver.py b/tests/machine/driver/ruidarpa/test_direct_driver.py index 460ecc0de..128093cc1 100644 --- a/tests/machine/driver/ruidarpa/test_direct_driver.py +++ b/tests/machine/driver/ruidarpa/test_direct_driver.py @@ -20,6 +20,8 @@ ("home_z", ()), ("focus_z", ()), ("jog_xy_to", (10.0, 20.0)), + ("jog_x_to", (10.0,)), + ("jog_y_to", (20.0,)), ("jog_xy_rel", (5.0, 5.0)), ("jog_x_rel", (5.0,)), ("jog_y_rel", (5.0,)), From 4f044eb4d4def2dad5b9c983e9178c08e9739ef2 Mon Sep 17 00:00:00 2001 From: Samuel Abels Date: Fri, 9 Oct 2026 13:06:15 +0000 Subject: [PATCH 035/104] fix: Sculpfun S30 focus laser uses M4 (no output at standstill) The S30 profile used M4 (dynamic laser power) for the focus laser. In M4 mode the laser output is proportional to motion speed, so the beam stays dark while the machine is stationary during focusing. Switch the focus command to M3 (constant power), matching the S30 Pro Max and most other diode profiles. Also add a dedicated Sculpfun S9 device profile (5.5W, 410x420mm) so S9 owners no longer need to select the S30. Fixes #512 --- .../devices/sculpfun-s30/dialect.yaml | 2 +- .../resources/devices/sculpfun-s9/device.yaml | 25 +++++++++++++++ .../devices/sculpfun-s9/dialect.yaml | 31 +++++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 rayforge/resources/devices/sculpfun-s9/device.yaml create mode 100644 rayforge/resources/devices/sculpfun-s9/dialect.yaml diff --git a/rayforge/resources/devices/sculpfun-s30/dialect.yaml b/rayforge/resources/devices/sculpfun-s30/dialect.yaml index 1e907b60b..3f68583f7 100644 --- a/rayforge/resources/devices/sculpfun-s30/dialect.yaml +++ b/rayforge/resources/devices/sculpfun-s30/dialect.yaml @@ -1,6 +1,6 @@ laser_on: M4 S{power:.0f} laser_off: M5 -focus_laser_on: M4 S{power:.0f} +focus_laser_on: M3 S{power:.0f} tool_change: T{tool_number} set_speed: '' travel_move: G0{x_cmd}{y_cmd}{z_cmd}{extra_cmd} diff --git a/rayforge/resources/devices/sculpfun-s9/device.yaml b/rayforge/resources/devices/sculpfun-s9/device.yaml new file mode 100644 index 000000000..582921889 --- /dev/null +++ b/rayforge/resources/devices/sculpfun-s9/device.yaml @@ -0,0 +1,25 @@ +api_version: 1 +device: + name: Sculpfun S9 + vendor: Sculpfun + model: S9 + description: 5.5W diode laser engraver with 410x420mm work area +machine: + driver: GrblSerialDriver + gcode_precision: 3 + axis_extents: + - 410.0 + - 420.0 + origin: bottom_left + max_travel_speed: 3000 + max_cut_speed: 1000 + has_z: false + heads: + - max_power: 1000 + wavelength_nm: 455 + max_power_watts: 5.5 + frame_power_percent: 1.0 + focus_power_percent: 1.0 + spot_size_mm: + - 0.06 + - 0.08 diff --git a/rayforge/resources/devices/sculpfun-s9/dialect.yaml b/rayforge/resources/devices/sculpfun-s9/dialect.yaml new file mode 100644 index 000000000..3f68583f7 --- /dev/null +++ b/rayforge/resources/devices/sculpfun-s9/dialect.yaml @@ -0,0 +1,31 @@ +laser_on: M4 S{power:.0f} +laser_off: M5 +focus_laser_on: M3 S{power:.0f} +tool_change: T{tool_number} +set_speed: '' +travel_move: G0{x_cmd}{y_cmd}{z_cmd}{extra_cmd} +linear_move: G1{x_cmd}{y_cmd}{z_cmd}{extra_cmd}{f_command} +arc_cw: G2{x_cmd}{y_cmd}{z_cmd}{extra_cmd} I{i} J{j}{f_command} +arc_ccw: G3{x_cmd}{y_cmd}{z_cmd}{extra_cmd} I{i} J{j}{f_command} +bezier_cubic: '' +air_assist_on: M8 +air_assist_off: M9 +home_all: $H +home_axis: $H{axis_letter} +move_to: $J=G90 G21 F{speed} X{x} Y{y} +jog: $J=G91 G21 F{speed} +clear_alarm: $X +set_wcs_offset: G10 L2 P{p_num} X{x} Y{y} Z{z} +probe_cycle: G38.2 {axis_letter}{max_travel} F{feed_rate} +dwell: G4 P{seconds:.3f} +preamble: +- G21 ;Set units to mm +- G90 ;Absolute positioning +postscript: +- M5 ;Ensure laser is off +- G0 X0 Y0 ;Return to origin +inject_wcs_after_preamble: true +can_g0_with_speed: false +omit_unchanged_coords: true +continuous_laser_mode: false +modal_feedrate: false From 7199db38fe26471b9fb4b1808e6d10eaec536ea6 Mon Sep 17 00:00:00 2001 From: Samuel Abels Date: Sun, 11 Oct 2026 14:02:27 +0200 Subject: [PATCH 036/104] fix: constrain snap store badge height in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 85df22067..7f3ed43f0 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Launchpad PPA](https://img.shields.io/badge/PPA-blue)](https://launchpad.net/~knipknap/+archive/ubuntu/rayforge) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -[![Get it from the Snap Store](https://snapcraft.io/en/light/install.svg)](https://snapcraft.io/rayforge) +Get it from the Snap Store Get it from Flathub Become a Patron From c4b1ad9a226849e4d1aeb1c935a3e6f01a118d16 Mon Sep 17 00:00:00 2001 From: Samuel Abels Date: Sun, 11 Oct 2026 22:00:27 +0200 Subject: [PATCH 037/104] fix: outside view settings are display-only The three outside_view_* properties only affect rendering, but their setters emitted settings_changed, which made the controller re-apply hardware settings and reset the denoising accumulator on every margin or transparency change. Emit changed only, and give the controller a separate handler for capture-relevant settings. The camera image element now also invalidates its caches on changed, so display-only changes still repaint promptly. --- rayforge/camera/controller.py | 13 +++++++---- rayforge/camera/models/camera.py | 3 --- .../ui_gtk/canvas2d/elements/camera_image.py | 4 ++++ tests/camera/test_camera.py | 5 +++- tests/camera/test_controller.py | 23 +++++++++++++++++++ tests/ui_gtk/canvas2d/test_camera_image.py | 16 +++++++++++++ 6 files changed, 56 insertions(+), 8 deletions(-) diff --git a/rayforge/camera/controller.py b/rayforge/camera/controller.py index b5dffc08b..87739a4ca 100644 --- a/rayforge/camera/controller.py +++ b/rayforge/camera/controller.py @@ -118,7 +118,7 @@ def __init__(self, config: Camera): self.resolutions_probed = Signal() self.config.changed.connect(self._on_config_changed) - self.config.settings_changed.connect(self._on_config_changed) + self.config.settings_changed.connect(self._on_capture_settings_changed) def _source_key(self) -> tuple: """A tuple identifying which physical source is being captured. @@ -136,9 +136,6 @@ def _on_config_changed(self, sender): return if not self.config.outside_view_enabled: self._outside_cache.clear() - self._settings_dirty = True - with self._frame_lock: - self._accumulator = None # Reset smoothing if settings change new_source_key = self._source_key() source_changed = new_source_key != self._last_source_key self._last_source_key = new_source_key @@ -153,6 +150,14 @@ def _on_config_changed(self, sender): # Also stop if it's disabled, regardless of subscribers self._stop_capture_stream() + def _on_capture_settings_changed(self, sender): + """Reacts to changes that affect capturing or frame processing.""" + if self._disposed: + return + self._settings_dirty = True + with self._frame_lock: + self._accumulator = None # Reset smoothing if settings change + @staticmethod def list_available_devices() -> list[str]: """ diff --git a/rayforge/camera/models/camera.py b/rayforge/camera/models/camera.py index 8abb0b3d4..601dbff2b 100644 --- a/rayforge/camera/models/camera.py +++ b/rayforge/camera/models/camera.py @@ -345,7 +345,6 @@ def outside_view_enabled(self, value: bool): ) self._outside_view_enabled = value self.changed.send(self) - self.settings_changed.send(self) @property def outside_view_margin_mm(self) -> float: @@ -374,7 +373,6 @@ def outside_view_margin_mm(self, value: float): ) self._outside_view_margin_mm = value self.changed.send(self) - self.settings_changed.send(self) @property def outside_view_transparency(self) -> float: @@ -402,7 +400,6 @@ def outside_view_transparency(self, value: float): ) self._outside_view_transparency = value self.changed.send(self) - self.settings_changed.send(self) @property def resolution(self) -> tuple[int, int] | None: diff --git a/rayforge/ui_gtk/canvas2d/elements/camera_image.py b/rayforge/ui_gtk/canvas2d/elements/camera_image.py index b0e4fd9d7..6dcee9a32 100644 --- a/rayforge/ui_gtk/canvas2d/elements/camera_image.py +++ b/rayforge/ui_gtk/canvas2d/elements/camera_image.py @@ -76,6 +76,9 @@ def __init__( self.camera = controller.config # Convenience alias for the data model self.controller.image_captured.connect(self._on_state_changed) self.camera.changed.connect(self._on_camera_model_changed) + # Display-only settings (e.g. the outside view) emit `changed` + # only, so both signals must invalidate the cache. + self.camera.changed.connect(self._on_state_changed) self.camera.settings_changed.connect(self._on_state_changed) self.set_visible(self.camera.enabled) @@ -116,6 +119,7 @@ def remove(self): """ self.controller.image_captured.disconnect(self._on_state_changed) self.camera.changed.disconnect(self._on_camera_model_changed) + self.camera.changed.disconnect(self._on_state_changed) self.camera.settings_changed.disconnect(self._on_state_changed) super().remove() diff --git a/tests/camera/test_camera.py b/tests/camera/test_camera.py index d13a91af7..e31866edb 100644 --- a/tests/camera/test_camera.py +++ b/tests/camera/test_camera.py @@ -564,9 +564,12 @@ def test_outside_view_setters_emit_signals(): camera.outside_view_margin_mm = 10 camera.outside_view_transparency = 0.5 assert len(changed) == 3 - assert len(settings_changed) == 3 + # The outside view is display-only; it must not invalidate capture + # or processing settings. + assert len(settings_changed) == 0 camera.outside_view_enabled = True camera.outside_view_margin_mm = 10.0 camera.outside_view_transparency = 0.5 assert len(changed) == 3 + assert len(settings_changed) == 0 diff --git a/tests/camera/test_controller.py b/tests/camera/test_controller.py index ee264f687..37b89ace9 100644 --- a/tests/camera/test_controller.py +++ b/tests/camera/test_controller.py @@ -702,6 +702,29 @@ def fake_start(): assert calls == ["start"] +def test_outside_view_change_does_not_dirty_capture_settings(): + """Outside view settings are display-only: they must not re-apply + hardware settings or reset the denoising accumulator.""" + camera = Camera("Test Camera", "0") + camera.denoise = 0.5 + controller = CameraController(camera) + controller._settings_dirty = False + with controller._frame_lock: + controller._accumulator = np.zeros((4, 4, 3), dtype=np.float32) + + camera.outside_view_margin_mm = 60 + camera.outside_view_transparency = 0.3 + camera.outside_view_enabled = True + + assert controller._settings_dirty is False + assert controller._accumulator is not None + + camera.contrast = 2.0 + + assert controller._settings_dirty is True + assert controller._accumulator is None + + def test_local_device_source_sets_read_timeout_when_supported(): camera = Camera("Local Camera", "0") set_calls = [] diff --git a/tests/ui_gtk/canvas2d/test_camera_image.py b/tests/ui_gtk/canvas2d/test_camera_image.py index a302081bd..18b98366e 100644 --- a/tests/ui_gtk/canvas2d/test_camera_image.py +++ b/tests/ui_gtk/canvas2d/test_camera_image.py @@ -154,6 +154,22 @@ def test_draw_does_not_schedule_duplicate_jobs(): assert glib.idle_add.call_count == 1 +def test_outside_view_change_invalidates_cached_surface(): + """Outside view settings emit `changed` only, which must still + invalidate the element's caches and queue a redraw.""" + controller = _controller() + controller.config.outside_view_enabled = True + element = _element(controller) + _process(element) + assert element._cached_key is not None + + with patch.object(element, "canvas") as canvas: + controller.config.outside_view_transparency = 0.4 + assert canvas.queue_draw.called + + assert element._cached_key is None + + def test_element_without_outside_support_keeps_its_clip(): controller = _controller() controller.config.outside_view_enabled = True From ffa096a570562f9eb0d811ce613229a3ae30e4dc Mon Sep 17 00:00:00 2001 From: Samuel Abels Date: Sun, 11 Oct 2026 22:01:46 +0200 Subject: [PATCH 038/104] fix: clear the raw camera frame together with the processed one _handle_frame_failure and _clear_image_data dropped only the processed frame, leaving a stale raw frame behind. The paired render API guards against using it today, but keeping the two buffers in sync makes the invariant hold by construction. Also document that the controller's outside-view cache is accessed from the GLib main loop only. --- rayforge/camera/controller.py | 5 ++++- tests/camera/test_controller.py | 24 ++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/rayforge/camera/controller.py b/rayforge/camera/controller.py index 87739a4ca..42bd5c5ff 100644 --- a/rayforge/camera/controller.py +++ b/rayforge/camera/controller.py @@ -84,6 +84,7 @@ def __init__(self, config: Camera): self._disposed: bool = False # Cached outside view coverage and remap tables, which only # change with calibration, alignment, and display geometry. + # Accessed from the GLib main loop only, like its callers. self._outside_cache: dict[str, tuple] = {} # Stream lifecycle: _lifecycle_lock serializes start/stop so that @@ -712,6 +713,7 @@ def _handle_frame_failure(self): self._consecutive_failures += 1 with self._frame_lock: self._image_data = None + self._raw_image_data = None return self._consecutive_failures >= self.MAX_CONSECUTIVE_FAILURES def _log_frame_failure(self, source) -> None: @@ -921,9 +923,10 @@ def _stop_locked(self): self._capture_thread = None def _clear_image_data(self) -> None: - """Drops the processed frame under the frame lock.""" + """Drops the captured frames under the frame lock.""" with self._frame_lock: self._image_data = None + self._raw_image_data = None def capture_image(self, *, apply_settings: bool = True): """ diff --git a/tests/camera/test_controller.py b/tests/camera/test_controller.py index 37b89ace9..aaf1f77be 100644 --- a/tests/camera/test_controller.py +++ b/tests/camera/test_controller.py @@ -1098,6 +1098,30 @@ def join(self, timeout=None): assert controller._running is False +def test_frame_failure_clears_both_frame_buffers(): + """The raw frame must not outlive the processed one, or the paired + render API could combine frames from different points in time.""" + camera = Camera("Test Camera", "0") + controller = CameraController(camera) + with controller._frame_lock: + controller._image_data = np.zeros((4, 4, 3), dtype=np.uint8) + controller._raw_image_data = np.zeros((4, 4, 3), dtype=np.uint8) + + controller._handle_frame_failure() + + assert controller._image_data is None + assert controller._raw_image_data is None + + with controller._frame_lock: + controller._image_data = np.zeros((4, 4, 3), dtype=np.uint8) + controller._raw_image_data = np.zeros((4, 4, 3), dtype=np.uint8) + + controller._clear_image_data() + + assert controller._image_data is None + assert controller._raw_image_data is None + + def test_dispose_disconnects_config_signals_and_stops_stream(): """dispose() must fully detach the controller from its camera config so a config mutation after teardown can never resurrect it, and must From 8ca6a0a674688d279148d41e4f75daa39485bc3b Mon Sep 17 00:00:00 2001 From: Samuel Abels Date: Sun, 11 Oct 2026 22:20:39 +0200 Subject: [PATCH 039/104] chore(deps): bump raygeo to 1.63.0 raygeo 1.63.0 ships the path clean-up operations this PR calls (close_open_contours, join_open_contours, remove_duplicate_contours, matches, split_drawn_contours; raygeo#56), so the pins can move off 1.62.0, which predates them. --- debian/requirements-bundle.txt | 2 +- pixi.lock | 68 +++++++++++++++++----------------- pixi.toml | 2 +- requirements.txt | 2 +- 4 files changed, 37 insertions(+), 37 deletions(-) diff --git a/debian/requirements-bundle.txt b/debian/requirements-bundle.txt index 0d43efc59..3129f9356 100644 --- a/debian/requirements-bundle.txt +++ b/debian/requirements-bundle.txt @@ -1,7 +1,7 @@ asyncudp==0.11.0 pyvips==3.2.0 raydriver==0.3.0 -raygeo==1.62.0 +raygeo==1.63.0 ruida-pa==0.21.2 trimesh==5.1.1 vtracer==0.6.15 \ No newline at end of file diff --git a/pixi.lock b/pixi.lock index 7390f5939..5c83c311b 100644 --- a/pixi.lock +++ b/pixi.lock @@ -247,11 +247,11 @@ environments: - pypi: https://files.pythonhosted.org/packages/13/d4/1152d1c7ab42d8b908be64fd200ddc870dc9d4925e951198702084aa1a7d/linkify_it_py-2.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/16/67/1f0b582e73ce46511f2799cbdcdbdf9ca98f344f2aad42ff5ff5dde0d8fe/narwhals-2.27.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/17/fe/77f4a24264e728fd95542e53f026a91249b51d1611087b8c82d3a033ea97/asyncudp-0.11.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/22/35/285ce45186a60572472c96df5646989ca97710e060419e5c749226a057cf/raygeo-1.63.0.tar.gz - pypi: https://files.pythonhosted.org/packages/31/c4/e54512d343c4c177e92c45ec16852abe63945b924274c692a5a1867db011/zeroconf-0.151.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3a/cf/9c2e4d6c179529d5bf4a64cff76fa581486569e9fbdd35bd98f51cb624bf/msgpack-1.2.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/3c/2c/c43c03eaf630435f023f1dc61ec4a4a78951ad5530a62c71cc89bde307b7/pypdf-6.19.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/3d/de/02f631997741485881fbe129aef2de75a23b4e9776f5b1c4a7afc313ae7e/raygeo-1.62.0.tar.gz - pypi: https://files.pythonhosted.org/packages/3f/99/2e119d541d596daea39643eb9312b47c7847383951300f889166938035b1/rpyc-6.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/41/51/8041c2e70649e5b7f2a0aedbbbd0609ac099cfaa0cbde2014279c9c05756/cairosvg-2.9.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/4f/a7/aeedb5140afa41fc74c225e9184ab96723a6e873b6ee1c9fede7283456d8/markupsafe-3.0.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl @@ -480,7 +480,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/13/d4/1152d1c7ab42d8b908be64fd200ddc870dc9d4925e951198702084aa1a7d/linkify_it_py-2.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/16/67/1f0b582e73ce46511f2799cbdcdbdf9ca98f344f2aad42ff5ff5dde0d8fe/narwhals-2.27.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/17/fe/77f4a24264e728fd95542e53f026a91249b51d1611087b8c82d3a033ea97/asyncudp-0.11.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/20/a5/1d1baffeb743cbdfff98c180473a9c81bbabe433dea5a72761d4c1edf1ef/raygeo-1.62.0-cp311-abi3-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3c/2c/c43c03eaf630435f023f1dc61ec4a4a78951ad5530a62c71cc89bde307b7/pypdf-6.19.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3f/99/2e119d541d596daea39643eb9312b47c7847383951300f889166938035b1/rpyc-6.0.2-py3-none-any.whl @@ -530,6 +529,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/fa/48/f36d233ce749d1bb880cb5e6f8bb171dc2effabc7435c31da604d831b3ec/gitpython-3.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/fc/4b/7cf8a68f8865e04e036a5b6847276f97075157ca2360048ba7fd60cd3b88/raygeo-1.63.0-cp311-abi3-macosx_11_0_arm64.whl build: channels: - url: https://conda.anaconda.org/conda-forge/ @@ -928,12 +928,12 @@ environments: - pypi: https://files.pythonhosted.org/packages/16/67/1f0b582e73ce46511f2799cbdcdbdf9ca98f344f2aad42ff5ff5dde0d8fe/narwhals-2.27.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/16/7d/e9ffbadfbf89c93848412d04594135c4ae8c1d37d9e053b9c3ed718fabc4/python_discovery-1.6.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/17/fe/77f4a24264e728fd95542e53f026a91249b51d1611087b8c82d3a033ea97/asyncudp-0.11.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/22/35/285ce45186a60572472c96df5646989ca97710e060419e5c749226a057cf/raygeo-1.63.0.tar.gz - pypi: https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/31/c4/e54512d343c4c177e92c45ec16852abe63945b924274c692a5a1867db011/zeroconf-0.151.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3a/cf/9c2e4d6c179529d5bf4a64cff76fa581486569e9fbdd35bd98f51cb624bf/msgpack-1.2.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/3c/2c/c43c03eaf630435f023f1dc61ec4a4a78951ad5530a62c71cc89bde307b7/pypdf-6.19.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/3d/de/02f631997741485881fbe129aef2de75a23b4e9776f5b1c4a7afc313ae7e/raygeo-1.62.0.tar.gz - pypi: https://files.pythonhosted.org/packages/3f/99/2e119d541d596daea39643eb9312b47c7847383951300f889166938035b1/rpyc-6.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/41/51/8041c2e70649e5b7f2a0aedbbbd0609ac099cfaa0cbde2014279c9c05756/cairosvg-2.9.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/45/e2/bbb7129c9e7999a6b8ee9cca3b66486c25c423ab5a75f34071798b74ce94/pre_commit-4.6.2-py2.py3-none-any.whl @@ -1188,7 +1188,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/16/67/1f0b582e73ce46511f2799cbdcdbdf9ca98f344f2aad42ff5ff5dde0d8fe/narwhals-2.27.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/16/7d/e9ffbadfbf89c93848412d04594135c4ae8c1d37d9e053b9c3ed718fabc4/python_discovery-1.6.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/17/fe/77f4a24264e728fd95542e53f026a91249b51d1611087b8c82d3a033ea97/asyncudp-0.11.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/20/a5/1d1baffeb743cbdfff98c180473a9c81bbabe433dea5a72761d4c1edf1ef/raygeo-1.62.0-cp311-abi3-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3c/2c/c43c03eaf630435f023f1dc61ec4a4a78951ad5530a62c71cc89bde307b7/pypdf-6.19.0-py3-none-any.whl @@ -1254,6 +1253,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/fa/70/fffc9613501877c0a10a1ef73a165e6ded3e53f6e7502227fef56d973933/identify-2.6.20-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/fc/4b/7cf8a68f8865e04e036a5b6847276f97075157ca2360048ba7fd60cd3b88/raygeo-1.63.0-cp311-abi3-macosx_11_0_arm64.whl video: channels: - url: https://conda.anaconda.org/conda-forge/ @@ -9647,7 +9647,7 @@ packages: name: rayforge requires_dist: - raydriver==0.3.0 - - raygeo==1.62.0 + - raygeo==1.63.0 - aiohttp==3.14.3 - asyncudp==0.11.0 - blinker==1.9.0 @@ -9820,25 +9820,25 @@ packages: name: asyncudp version: 0.11.0 sha256: 96d859d86471e3ed30cf9f05cb38e96920156e3a9fe70ee916096ba758ea316e -- pypi: https://files.pythonhosted.org/packages/20/a5/1d1baffeb743cbdfff98c180473a9c81bbabe433dea5a72761d4c1edf1ef/raygeo-1.62.0-cp311-abi3-macosx_11_0_arm64.whl +- pypi: https://files.pythonhosted.org/packages/22/35/285ce45186a60572472c96df5646989ca97710e060419e5c749226a057cf/raygeo-1.63.0.tar.gz name: raygeo - version: 1.62.0 - sha256: 094187c356b390e729a8fa7287b97f7f657b26a280cf2014c6726e944786a4a3 + version: 1.63.0 + sha256: 6a411c28e7aecea81fa7ccb3d34c652ed1ff4bf1fa677a09323fdd00988a77c8 requires_dist: - msgpack - numpy>=1.20.0 - - matplotlib ; extra == 'cli' - - matplotlib ; extra == 'docs' - - mdformat ; extra == 'docs' - - mdformat-frontmatter ; extra == 'docs' - - mdformat-tables ; extra == 'docs' - - pillow ; extra == 'docs' - pycairo ; extra == 'test' - pytest ; extra == 'test' - pytest-mock ; extra == 'test' - streamlit ; extra == 'test' - streamlit ; extra == 'visual' - matplotlib ; extra == 'visual' + - matplotlib ; extra == 'docs' + - mdformat ; extra == 'docs' + - mdformat-frontmatter ; extra == 'docs' + - mdformat-tables ; extra == 'docs' + - pillow ; extra == 'docs' + - matplotlib ; extra == 'cli' requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl name: mccabe @@ -9894,26 +9894,6 @@ packages: - arabic-reshaper ; extra == 'rtl-text' - python-bidi ; extra == 'rtl-text' requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/3d/de/02f631997741485881fbe129aef2de75a23b4e9776f5b1c4a7afc313ae7e/raygeo-1.62.0.tar.gz - name: raygeo - version: 1.62.0 - sha256: 45b319ba805362aaa288532a0f0b0394b0898f39724c9b6a7ae378c463ed2346 - requires_dist: - - msgpack - - numpy>=1.20.0 - - pycairo ; extra == 'test' - - pytest ; extra == 'test' - - pytest-mock ; extra == 'test' - - streamlit ; extra == 'test' - - streamlit ; extra == 'visual' - - matplotlib ; extra == 'visual' - - matplotlib ; extra == 'docs' - - mdformat ; extra == 'docs' - - mdformat-frontmatter ; extra == 'docs' - - mdformat-tables ; extra == 'docs' - - pillow ; extra == 'docs' - - matplotlib ; extra == 'cli' - requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/3f/99/2e119d541d596daea39643eb9312b47c7847383951300f889166938035b1/rpyc-6.0.2-py3-none-any.whl name: rpyc version: 6.0.2 @@ -10801,6 +10781,26 @@ packages: - tree-sitter-yaml>=0.6.0 ; python_full_version >= '3.10' and extra == 'syntax' - typing-extensions>=4.4.0,<5.0.0 requires_python: '>=3.9,<4.0' +- pypi: https://files.pythonhosted.org/packages/fc/4b/7cf8a68f8865e04e036a5b6847276f97075157ca2360048ba7fd60cd3b88/raygeo-1.63.0-cp311-abi3-macosx_11_0_arm64.whl + name: raygeo + version: 1.63.0 + sha256: 80cc04f6a7c723c4c1a6b2cf5aee1491fc99a41ad499c7100623cbbe2cd96022 + requires_dist: + - msgpack + - numpy>=1.20.0 + - matplotlib ; extra == 'cli' + - matplotlib ; extra == 'docs' + - mdformat ; extra == 'docs' + - mdformat-frontmatter ; extra == 'docs' + - mdformat-tables ; extra == 'docs' + - pillow ; extra == 'docs' + - pycairo ; extra == 'test' + - pytest ; extra == 'test' + - pytest-mock ; extra == 'test' + - streamlit ; extra == 'test' + - streamlit ; extra == 'visual' + - matplotlib ; extra == 'visual' + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/ff/72/b12534f2d335448bc98504610c8d57520e530dab840251871a72cbb6c227/vtracer-0.6.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl name: vtracer version: 0.6.15 diff --git a/pixi.toml b/pixi.toml index b9c8738b2..750d0a29c 100644 --- a/pixi.toml +++ b/pixi.toml @@ -38,7 +38,7 @@ rust = "==1.98.1" patchelf = "==0.19.2" [feature.app.pypi-dependencies] -raygeo = "==1.62.0" +raygeo = "==1.63.0" raydriver = "==0.3.0" rayforge = { editable = true, path = "." } ruida-pa = "==0.22.0" diff --git a/requirements.txt b/requirements.txt index b46963b07..db6e926a0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ raydriver==0.3.0 -raygeo==1.62.0 +raygeo==1.63.0 aiohttp==3.14.3 asyncudp==0.11.0 blinker==1.9.0 From 44800f730ee107a3834870fabeafebdc3999176e Mon Sep 17 00:00:00 2001 From: Samuel Abels Date: Sun, 11 Oct 2026 22:56:36 +0200 Subject: [PATCH 040/104] docs: document the view outside the workspace Add a View Outside the Workspace section to the camera page and its translations, with a new main-window screenshot target showing the feature. The camera settings screenshot is regenerated with an aligned mock camera and the outside view enabled, so the new settings group shows in its active state; the settings script now shares its mock camera helpers with the new script and scrolls the page to the bottom before capturing. --- scripts/screenshot/cli.py | 1 + scripts/screenshot/machine_settings_camera.py | 90 ++++++++++------- .../screenshot/main_camera_outside_view.py | 95 ++++++++++++++++++ scripts/screenshot/utils.py | 53 ++++++++++ website/docs/machine/camera.md | 19 ++++ .../current/machine/camera.md | 20 ++++ .../current/machine/camera.md | 20 ++++ .../current/machine/camera.md | 21 ++++ .../current/machine/camera.md | 19 ++++ .../current/machine/camera.md | 19 ++++ .../current/machine/camera.md | 19 ++++ .../current/machine/camera.md | 14 +++ .../screenshots/machine-settings-camera.webp | Bin 32328 -> 37622 bytes .../screenshots/main-camera-outside-view.webp | Bin 0 -> 487522 bytes 14 files changed, 353 insertions(+), 37 deletions(-) create mode 100644 scripts/screenshot/main_camera_outside_view.py create mode 100644 website/static/screenshots/main-camera-outside-view.webp diff --git a/scripts/screenshot/cli.py b/scripts/screenshot/cli.py index 880a53fd8..98dae1bf2 100755 --- a/scripts/screenshot/cli.py +++ b/scripts/screenshot/cli.py @@ -79,6 +79,7 @@ "main:3d": "main_3d", "main:3d-bee": "main_3d_bee", "main:3d-rotary": "main_3d_rotary", + "main:camera-outside-view": "main_camera_outside_view", "main:array:grid": "array_grid", "main:array:point-rotation": "array_point_rotation", "main:array:circular": "array_circular", diff --git a/scripts/screenshot/machine_settings_camera.py b/scripts/screenshot/machine_settings_camera.py index 093a5e4e4..96450504d 100644 --- a/scripts/screenshot/machine_settings_camera.py +++ b/scripts/screenshot/machine_settings_camera.py @@ -3,12 +3,12 @@ import logging import subprocess import time -from pathlib import Path -import cv2 -from gi.repository import GLib +from gi.repository import GLib, Gtk from utils import ( + align_mock_camera, get_target, + inject_mock_image, open_machine_settings, restore_config, run_on_main_thread, @@ -32,14 +32,6 @@ PAGE = "camera" TARGET = get_target(f"machine-settings:{PAGE}") -MOCK_IMAGE_PATH = ( - Path(__file__).parent.parent.parent - / "website" - / "static" - / "images" - / "work-surface.png" -) - WIZARD_PAGES = ("card", "capture") DIALOGS = { @@ -70,30 +62,6 @@ def parse_target(target: str) -> dict | None: return None -def load_mock_image(): - """Load the mock camera image as a BGR numpy array.""" - img = cv2.imread(str(MOCK_IMAGE_PATH), cv2.IMREAD_COLOR) - if img is None: - logger.error("Failed to load mock image from %s", MOCK_IMAGE_PATH) - return None - logger.info("Loaded mock image: %s (%s)", MOCK_IMAGE_PATH, img.shape) - return img - - -def inject_mock_image(controller): - """Inject mock image data into the controller and trigger redraw.""" - img = load_mock_image() - if img is None: - return False - controller._raw_image_data = img.copy() - controller._image_data = img.copy() - GLib.idle_add(controller.image_captured.send, controller) - # Prevent the real capture loop from starting (mock device can't open) - controller._running = True - time.sleep(0.25) - return True - - def add_mock_camera(dialog): """Add a mock camera, select it in the list, and inject mock image.""" machine = dialog.machine @@ -140,6 +108,47 @@ def _has_camera(): return None, controller +def _iter_descendants(widget): + """Yield widget and all of its descendants, depth-first.""" + child = widget.get_first_child() + while child: + yield child + yield from _iter_descendants(child) + child = child.get_next_sibling() + + +def _scroll_camera_page_to_bottom(dialog): + """Scroll the camera page so its bottommost group is visible. + + The value is pinned repeatedly rather than set once, because the + page scrolls smoothly and a single set is only captured in-flight. + """ + + def _find_adjustment(): + for widget in _iter_descendants(dialog.camera_page): + if isinstance(widget, Gtk.ScrolledWindow): + adjustment = widget.get_vadjustment() + if adjustment.get_upper() > adjustment.get_page_size(): + return adjustment + return None + + def _scroll(): + adjustment = _find_adjustment() + if adjustment is None: + logger.warning("No scroll adjustment found for the camera page") + return + remaining = [30] + + def _pin(): + adjustment.set_value(adjustment.get_upper()) + remaining[0] -= 1 + return remaining[0] > 0 + + GLib.timeout_add(50, _pin) + + run_on_main_thread(_scroll) + + def take_wizard_screenshot(parent_dialog, wizard_page: str): """Open the lens calibration wizard directly and take a screenshot.""" @@ -208,8 +217,15 @@ def main(): time.sleep(0.25) if target_info is None: - setup_camera_page(dialog) - time.sleep(0.25) + camera, controller = setup_camera_page(dialog) + camera = camera or (controller.config if controller else None) + if camera is not None: + width_mm, height_mm = dialog.machine.axis_extents + align_mock_camera(camera, width_mm, height_mm) + # Show the outside-workspace settings in their enabled state. + camera.outside_view_enabled = True + _scroll_camera_page_to_bottom(dialog) + time.sleep(1.8) take_screenshot() time.sleep(0.25) app.quit_idle() diff --git a/scripts/screenshot/main_camera_outside_view.py b/scripts/screenshot/main_camera_outside_view.py new file mode 100644 index 000000000..a31014f25 --- /dev/null +++ b/scripts/screenshot/main_camera_outside_view.py @@ -0,0 +1,95 @@ +""" +Screenshot: Main window with the camera view outside the workspace. + +Usage: pixi run screenshot main:camera-outside-view +""" + +import logging +import time + +from gi.repository import GLib +from utils import ( + align_mock_camera, + clear_window_subtitle, + inject_mock_image, + restore_config, + run_on_main_thread, + set_window_size, + take_screenshot, + wait_for_settled, +) + +from rayforge.camera.models.camera import Camera +from rayforge.context import get_context +from rayforge.ui_gtk.canvas2d.elements.camera_image import CameraImageElement +from rayforge.uiscript import app, win + +logger = logging.getLogger(__name__) + + +@restore_config +def main(): + set_window_size(win, 2400, 1650) + + machine = get_context().config.machine + width_mm, height_mm = machine.axis_extents + + camera = Camera("Test Camera", "mock-device-0") + camera.enabled = True + camera.transparency = 0.65 + align_mock_camera(camera, width_mm, height_mm) + camera.outside_view_margin_mm = 30 + camera.outside_view_enabled = True + + def _add_camera(): + machine.add_camera(camera) + return camera + + run_on_main_thread(_add_camera) + time.sleep(0.5) + + controller = get_context().camera_mgr.get_controller(camera.id) + if not controller: + logger.error("Failed to create mock camera controller") + app.quit_idle() + return + + def _sync_elements(): + # The work surface only picks up new cameras on machine changes, + # which can race the creation of the controller. Re-sync now that + # the controller definitely exists. + win.surface._sync_camera_elements() + + run_on_main_thread(_sync_elements) + inject_mock_image(controller) + + def _show_camera_overlay(): + action = win.action_manager.get_action("toggle_camera_view") + action.change_state(GLib.Variant.new_boolean(True)) + # Zoom out so the margin around the workspace is on screen. + win.surface.set_pan(0.0, 0.0) + win.surface.set_zoom(0.72) + + run_on_main_thread(_show_camera_overlay) + time.sleep(1.0) + + def _check(): + elems = list(win.surface.find_by_type(CameraImageElement)) + if not elems or not elems[0].visible: + logger.error("Camera element is not visible on the work surface") + return False + + GLib.timeout_add(500, _check) + if not wait_for_settled(win, timeout=10): + logger.error("Document did not settle in time") + app.quit_idle() + return + + clear_window_subtitle(win) + logger.info("Taking screenshot: main-camera-outside-view.webp") + take_screenshot() + time.sleep(0.25) + app.quit_idle() + + +main() diff --git a/scripts/screenshot/utils.py b/scripts/screenshot/utils.py index 027356476..bbf677ae0 100644 --- a/scripts/screenshot/utils.py +++ b/scripts/screenshot/utils.py @@ -26,6 +26,7 @@ TypeVar, ) +import cv2 import numpy as np import pyvips from gi.repository import Adw, GdkX11, GLib, Gtk @@ -1086,6 +1087,58 @@ def _load() -> None: logger.info(f"Loaded project: {project_name}") +MOCK_IMAGE_PATH = ( + PROJECT_ROOT / "website" / "static" / "images" / "work-surface.png" +) + +# The region of the mock image that maps onto the machine bed. The +# surrounding image content becomes the camera view outside the +# workspace. +MOCK_BED_REGION = (120, 60, 520, 420) + + +def load_mock_image() -> "np.ndarray | None": + """Load the mock camera image as a BGR numpy array.""" + img = cv2.imread(str(MOCK_IMAGE_PATH), cv2.IMREAD_COLOR) + if img is None: + logger.error("Failed to load mock image from %s", MOCK_IMAGE_PATH) + return None + logger.info("Loaded mock image: %s (%s)", MOCK_IMAGE_PATH, img.shape) + return img + + +def inject_mock_image(controller) -> bool: + """Inject mock image data into the controller and trigger redraw.""" + img = load_mock_image() + if img is None: + return False + controller._raw_image_data = img.copy() + controller._image_data = img.copy() + GLib.idle_add(controller.image_captured.send, controller) + # Prevent the real capture loop from starting (mock device can't open) + controller._running = True + time.sleep(0.25) + return True + + +def align_mock_camera(camera, bed_width_mm: float, bed_height_mm: float): + """Align a mock camera so the mock image covers the whole bed. + + Maps an inset region of the mock image onto the bed rectangle, so + the remaining image content shows up outside the workspace. + """ + x0, y0, x1, y1 = MOCK_BED_REGION + camera.image_to_world = ( + [(x0, y0), (x1, y0), (x1, y1), (x0, y1)], + [ + (0, bed_height_mm), + (bed_width_mm, bed_height_mm), + (bed_width_mm, 0), + (0, 0), + ], + ) + + def set_window_size( win: "MainWindow", width: int, height: int, timeout: float = 1.0 ) -> bool: diff --git a/website/docs/machine/camera.md b/website/docs/machine/camera.md index 9ef1f6b03..e41870996 100644 --- a/website/docs/machine/camera.md +++ b/website/docs/machine/camera.md @@ -356,6 +356,25 @@ icon in the main window toolbar. --- +### View Outside the Workspace + +The camera usually sees more than just the work area. In Machine settings → Camera → View Outside +Workspace, you can show an additional semi-transparent border of camera image around the workspace, +which makes it easier to position workpieces. + +![Camera view outside the workspace](/screenshots/main-camera-outside-view.webp) + +- **Show Outside Workspace** turns the border on or off. It is off by default and requires an + aligned camera. +- **Margin** controls how far the border extends beyond the work area, up to 100 mm. +- **Transparency** controls how visible the border is. + +Areas the camera cannot see stay transparent instead of showing black, and the border ends at the +edge of what the camera actually covers. Objects sticking up above the bed, such as the gantry, can +appear shifted in the border, because the camera image is aligned to the bed plane. + +--- + ### Multiple Cameras Rayforge supports multiple cameras for different views or machines: diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/machine/camera.md b/website/i18n/de/docusaurus-plugin-content-docs/current/machine/camera.md index 6ca39dfc4..9088cb1f7 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/machine/camera.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/machine/camera.md @@ -298,6 +298,26 @@ Klicken auf das Kamerasymbol in der Hauptfenster-Symbolleiste ein oder aus. --- +### Ansicht außerhalb des Arbeitsbereichs + +Die Kamera sieht meist mehr als nur den Arbeitsbereich. Unter Maschineneinstellungen → Kamera → +Ansicht außerhalb des Arbeitsbereichs kannst du einen zusätzlichen halbtransparenten Rand aus +Kamerabild um den Arbeitsbereich anzeigen lassen, der das Positionieren von Werkstücken erleichtert. + +![Kameraansicht außerhalb des Arbeitsbereichs](/screenshots/main-camera-outside-view.webp) + +- **Außerhalb des Arbeitsbereichs anzeigen** schaltet den Rand ein oder aus. Er ist standardmäßig + deaktiviert und erfordert eine ausgerichtete Kamera. +- **Rand** legt fest, wie weit der Rand über den Arbeitsbereich hinausgeht, bis zu 100 mm. +- **Transparenz** steuert, wie gut der Rand sichtbar ist. + +Bereiche, die die Kamera nicht sieht, bleiben transparent, statt schwarz zu erscheinen, und der Rand +endet an der Kante dessen, was die Kamera tatsächlich abdeckt. Objekte, die über das Bett ragen, wie +der Portalrahmen, können im Rand verschoben erscheinen, da das Kamerabild auf die Bettebene +ausgerichtet ist. + +--- + ### Mehrere Kameras Rayforge unterstützt mehrere Kameras für verschiedene Ansichten oder Maschinen: diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/machine/camera.md b/website/i18n/es/docusaurus-plugin-content-docs/current/machine/camera.md index 35a3ce9ee..15d626375 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/machine/camera.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/machine/camera.md @@ -303,6 +303,26 @@ haciendo clic en el icono de cámara en la barra de herramientas de la ventana p --- +### Vista fuera del área de trabajo + +La cámara normalmente ve más que solo el área de trabajo. En Configuración de máquina → Cámara → +Vista fuera del área de trabajo, puedes mostrar un borde adicional semitransparente con la imagen de +la cámara alrededor del área de trabajo, lo que facilita posicionar las piezas. + +![Vista de la cámara fuera del área de trabajo](/screenshots/main-camera-outside-view.webp) + +- **Mostrar fuera del área de trabajo** activa o desactiva el borde. Está desactivado por defecto y + requiere una cámara alineada. +- **Margen** controla hasta dónde se extiende el borde más allá del área de trabajo, hasta 100 mm. +- **Transparencia** controla la visibilidad del borde. + +Las zonas que la cámara no puede ver permanecen transparentes en lugar de mostrarse en negro, y el +borde termina donde la cámara deja de ver. Los objetos que sobresalen por encima de la cama, como el +pórtico, pueden aparecer desplazados en el borde, porque la imagen de la cámara está alineada con el +plano de la cama. + +--- + ### Múltiples Cámaras Rayforge soporta múltiples cámaras para diferentes vistas o máquinas: diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/machine/camera.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/machine/camera.md index d5f09c25c..37ec2b57b 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/machine/camera.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/machine/camera.md @@ -305,6 +305,27 @@ en cliquant sur l'icône caméra dans la barre d'outils de la fenêtre principal --- +### Vue hors de la zone de travail + +La caméra voit généralement plus que la seule zone de travail. Dans Paramètres de la machine → +Caméra → Vue hors de la zone de travail, tu peux afficher une bordure supplémentaire +semi-transparente de l'image caméra autour de la zone de travail, ce qui facilite le positionnement +des pièces. + +![Vue caméra hors de la zone de travail](/screenshots/main-camera-outside-view.webp) + +- **Afficher hors de la zone de travail** active ou désactive la bordure. Elle est désactivée par + défaut et nécessite une caméra alignée. +- **Marge** contrôle la distance dont la bordure dépasse de la zone de travail, jusqu'à 100 mm. +- **Transparence** contrôle la visibilité de la bordure. + +Les zones que la caméra ne voit pas restent transparentes au lieu d'apparaître en noir, et la +bordure s'arrête à la limite de ce que la caméra couvre réellement. Les objets dépassant au-dessus +du lit, comme le portique, peuvent sembler décalés dans la bordure, car l'image caméra est alignée +sur le plan du lit. + +--- + ### Caméras Multiples Rayforge supporte plusieurs caméras pour différentes vues ou machines : diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/machine/camera.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/machine/camera.md index e5074b75a..cfc640682 100644 --- a/website/i18n/hi/docusaurus-plugin-content-docs/current/machine/camera.md +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/machine/camera.md @@ -300,6 +300,25 @@ YUYV विकल्प तब उपयोगी है जब आपका क --- +### वर्कस्पेस के बाहर का दृश्य + +कैमरा आमतौर पर केवल वर्कस्पेस से अधिक देखता है. मशीन सेटिंग्स → कैमरा → वर्कस्पेस के बाहर का दृश्य +में, आप वर्कस्पेस के चारों ओर कैमरा इमेज का एक अतिरिक्त अर्ध-पारदर्शी किनारा दिखा सकते हैं, जिससे +वर्कपीस को स्थित करना आसान हो जाता है. + +![वर्कस्पेस के बाहर कैमरा दृश्य](/screenshots/main-camera-outside-view.webp) + +- **वर्कस्पेस के बाहर दिखाएं** किनारे को चालू या बंद करता है. यह डिफ़ॉल्ट रूप से बंद रहता है और + संरेखित कैमरे की आवश्यकता होती है. +- **मार्जिन** यह नियंत्रित करता है कि किनारा वर्कस्पेस से कितनी दूर तक फैले, 100 मिमी तक. +- **पारदर्शिता** किनारे की दृश्यता को नियंत्रित करती है. + +जहां कैमरा नहीं देख सकता, वहां काला दिखाने के बजाय पारदर्शी रहता है, और किनारा उसी सीमे पर समाप्त +होता है जहां तक कैमरा वास्तव में देखता है. बेड से ऊपर उठी वस्तुएं, जैसे गैंट्री, किनारे में खिसकी +हुई दिख सकती हैं, क्योंकि कैमरा इमेज बेड के तल से संरेखित होती है. + +--- + ### कई कैमरे Rayforge भिन्न दृश्यों या मशीनों के लिए कई कैमरे समर्थन करता है: diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/machine/camera.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/machine/camera.md index 8dbad56c4..eebfb915b 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/machine/camera.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/machine/camera.md @@ -296,6 +296,25 @@ clicando no ícone da câmera na barra de ferramentas da janela principal. --- +### Visualização fora da área de trabalho + +A câmera geralmente vê mais do que apenas a área de trabalho. Em Configurações da máquina → Câmera → +Visualização fora da área de trabalho, você pode exibir uma borda adicional semitransparente com a +imagem da câmera ao redor da área de trabalho, o que facilita posicionar as peças. + +![Visualização da câmera fora da área de trabalho](/screenshots/main-camera-outside-view.webp) + +- **Mostrar fora da área de trabalho** liga ou desliga a borda. Ela fica desligada por padrão e + exige uma câmera alinhada. +- **Margem** controla até onde a borda se estende além da área de trabalho, até 100 mm. +- **Transparência** controla a visibilidade da borda. + +As áreas que a câmera não consegue ver permanecem transparentes em vez de aparecer em preto, e a +borda termina no limite do que a câmera realmente cobre. Objetos que se elevam acima da mesa, como o +pórtico, podem aparecer deslocados na borda, porque a imagem da câmera é alinhada ao plano da mesa. + +--- + ### Múltiplas Câmeras O Rayforge suporta múltiplas câmeras para diferentes visões ou máquinas: diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/machine/camera.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/machine/camera.md index 2ab33cdbf..5561afeec 100644 --- a/website/i18n/uk/docusaurus-plugin-content-docs/current/machine/camera.md +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/machine/camera.md @@ -289,6 +289,25 @@ Offset** на 0. --- +### Вигляд поза робочою областю + +Камера зазвичай бачить більше, ніж лише робочу область. У Налаштуваннях машини → Камера → Вигляд +поза робочою областю можна показати додаткову напівпрозору рамку з зображення камери навколо робочої +області, що полегшує позиціонування заготовок. + +![Вигляд камери поза робочою областю](/screenshots/main-camera-outside-view.webp) + +- **Показувати поза робочою областю** вмикає або вимикає рамку. Типово вона вимкнена та потребує + вирівняної камери. +- **Поле** визначає, наскільки рамка виходить за межі робочої області, до 100 мм. +- **Прозорість** визначає помітність рамки. + +Ділянки, які камера не бачить, залишаються прозорими замість чорних, а рамка закінчується на межі +того, що камера фактично охоплює. Об'єкти, що виступають над столом, як-от портал, можуть виглядати +зміщеними в рамці, оскільки зображення камери вирівняне до площини столу. + +--- + ### Кілька камер Rayforge підтримує кілька камер для різних видів або машин: diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/machine/camera.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/machine/camera.md index 5844079f8..78da1ed66 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/machine/camera.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/machine/camera.md @@ -246,6 +246,20 @@ Rayforge 默认推荐交错纸,因此如果您的纸是普通矩形排列, --- +### 工作区域之外的视图 + +相机通常能看到工作区域以外的内容。在 机器设置 → 相机 → 工作区域之外的视图 中,可以在工作区域周围显示一圈额外的半透明相机图像边框,便于放置工件。 + +![工作区域之外的相机视图](/screenshots/main-camera-outside-view.webp) + +- **显示工作区域之外** 用于开关该边框。默认关闭,且需要已完成对齐的相机。 +- **边距** 控制边框超出工作区域的距离,最大 100 毫米。 +- **透明度** 控制边框的可见程度。 + +相机看不到的区域保持透明而不是显示为黑色,边框也会在相机实际覆盖范围的边缘处截止。高于床面的物体(如横梁)在边框中可能出现偏移,因为相机图像是对齐到床面平面的。 + +--- + ### 多相机 Rayforge 支持多个相机用于不同视图或机器: diff --git a/website/static/screenshots/machine-settings-camera.webp b/website/static/screenshots/machine-settings-camera.webp index a4a0be6e905008dc3a4f79d43c4427fe1cabf073..18a479bd1bab5cf9f65c9edef2d641340a15d993 100644 GIT binary patch literal 37622 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

$E_uEE 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 literal 32328 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 z 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 b-xU5ADpg diff --git a/website/static/screenshots/main-camera-outside-view.webp b/website/static/screenshots/main-camera-outside-view.webp new file mode 100644 index 0000000000000000000000000000000000000000..62ed76f90177bb146192f0dc55a8c85c66080f30 GIT binary patch literal 487522 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

f?)(xgvI)wLlL{Ubfsb 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 z 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%0D^WSLLJJK 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 literal 0 HcmV?d00001 From ef13caa896aad1e6ffa9064598723ff5b4e8e1c6 Mon Sep 17 00:00:00 2001 From: Samuel Abels Date: Sun, 11 Oct 2026 23:21:09 +0200 Subject: [PATCH 041/104] fix(ui): keep the tolerance row title on one line The Close/Join Paths dialog rendered at Adw.AlertDialog's default content width of 300 px, which wrapped the spin row's title mid-word and stacked the response buttons. Give the preferences group a minimum width so the title and buttons fit. --- rayforge/ui_gtk/doceditor/path_tolerance_dialog.py | 1 + 1 file changed, 1 insertion(+) diff --git a/rayforge/ui_gtk/doceditor/path_tolerance_dialog.py b/rayforge/ui_gtk/doceditor/path_tolerance_dialog.py index 4673e6f35..b19491f6d 100644 --- a/rayforge/ui_gtk/doceditor/path_tolerance_dialog.py +++ b/rayforge/ui_gtk/doceditor/path_tolerance_dialog.py @@ -53,6 +53,7 @@ def __init__( ) group = Adw.PreferencesGroup() group.add(self.tolerance_row) + group.set_size_request(430, -1) self.set_extra_child(group) self.connect("response", self._on_response) From f3aaef07c7442861476a0298a80ed5990e9abfab Mon Sep 17 00:00:00 2001 From: Samuel Abels Date: Sun, 11 Oct 2026 23:22:02 +0200 Subject: [PATCH 042/104] feat(screenshot): add main:path-cleanup target Captures the Close Paths dialog over a selected vector workpiece for the path clean-up documentation. --- scripts/screenshot/cli.py | 1 + scripts/screenshot/path_cleanup.py | 96 ++++++++++++++++++ .../static/screenshots/main-path-cleanup.webp | Bin 0 -> 161480 bytes 3 files changed, 97 insertions(+) create mode 100644 scripts/screenshot/path_cleanup.py create mode 100644 website/static/screenshots/main-path-cleanup.webp diff --git a/scripts/screenshot/cli.py b/scripts/screenshot/cli.py index 880a53fd8..d134be3cf 100755 --- a/scripts/screenshot/cli.py +++ b/scripts/screenshot/cli.py @@ -82,6 +82,7 @@ "main:array:grid": "array_grid", "main:array:point-rotation": "array_point_rotation", "main:array:circular": "array_circular", + "main:path-cleanup": "path_cleanup", "addons:sketcher:conflicts": "sketcher_constraints", "addons:sketcher:constraints": "sketcher_editor", "addons:sketcher:constraint:angle": "sketcher_constraints", diff --git a/scripts/screenshot/path_cleanup.py b/scripts/screenshot/path_cleanup.py new file mode 100644 index 000000000..0e226ff0a --- /dev/null +++ b/scripts/screenshot/path_cleanup.py @@ -0,0 +1,96 @@ +""" +Screenshot: Close Paths tolerance dialog with a vector workpiece selected. + +Usage: pixi run screenshot main:path-cleanup +""" + +import logging +import time +from gettext import gettext as _ + +from utils import ( + clear_window_subtitle, + load_project, + restore_config, + run_on_main_thread, + set_window_size, + take_window_screenshot, + wait_for_settled, +) + +from rayforge.uiscript import app, win + +logger = logging.getLogger(__name__) + + +def _select_all_items(): + """Select all content items on the active layer.""" + win.surface.update_from_doc() + layer = win.doc_editor.doc.active_layer + items = layer.get_content_items() + if items: + win.surface.select_items(items) + return items + + +def _open_close_paths_dialog(): + from rayforge.ui_gtk.doceditor.path_tolerance_dialog import ( + PathToleranceDialog, + ) + + def _apply(tolerance: float): + pass + + dialog = PathToleranceDialog( + heading=_("Close Paths"), + body=_( + "Closes open paths whose start and end point are closer " + "than the tolerance." + ), + apply_label=_("Close Paths"), + key="close-paths", + on_apply=_apply, + ) + dialog.present(win) + return dialog + + +@restore_config +def main(): + set_window_size(win, 2000, 1400) + + load_project(win, "contour.ryp") + logger.info("Waiting for document to settle...") + if not wait_for_settled(win, timeout=10): + logger.error("Document did not settle in time") + app.quit_idle() + return + + logger.info("Document settled") + + items = run_on_main_thread(_select_all_items) + if not items: + logger.error("No items found in document") + app.quit_idle() + return + + logger.info(f"Selected {len(items)} items") + + dialog = run_on_main_thread(_open_close_paths_dialog) + time.sleep(0.5) + + clear_window_subtitle(win) + + logger.info("Taking screenshot: main-path-cleanup.webp") + take_window_screenshot(win) + + time.sleep(0.25) + + def close_dialog(): + dialog.close() + + run_on_main_thread(close_dialog) + app.quit_idle() + + +main() diff --git a/website/static/screenshots/main-path-cleanup.webp b/website/static/screenshots/main-path-cleanup.webp new file mode 100644 index 0000000000000000000000000000000000000000..85a6a0610724b58921a1380e7540de1dedff7cb9 GIT binary patch literal 161480 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 Date: Sun, 11 Oct 2026 23:22:46 +0200 Subject: [PATCH 043/104] docs: fix internal links and embed the Close Paths dialog Use relative file paths ending in .md for the internal links in the path clean-up docs and the canvas-tools cross-reference, matching the convention from 8043b6593 (.md-less links broke client-side navigation), in English and all translations. Each language's page also embeds the new screenshot. --- website/docs/features/path-cleanup.md | 12 +++++++----- website/docs/ui/canvas-tools.md | 2 +- .../current/features/path-cleanup.md | 13 ++++++++----- .../current/ui/canvas-tools.md | 2 +- .../current/features/path-cleanup.md | 12 +++++++----- .../current/ui/canvas-tools.md | 2 +- .../current/features/path-cleanup.md | 13 ++++++++----- .../current/ui/canvas-tools.md | 2 +- .../current/features/path-cleanup.md | 12 +++++++----- .../current/ui/canvas-tools.md | 2 +- .../current/features/path-cleanup.md | 12 +++++++----- .../current/ui/canvas-tools.md | 2 +- .../current/features/path-cleanup.md | 12 +++++++----- .../current/ui/canvas-tools.md | 2 +- .../current/features/path-cleanup.md | 12 +++++++----- .../current/ui/canvas-tools.md | 2 +- 16 files changed, 66 insertions(+), 48 deletions(-) diff --git a/website/docs/features/path-cleanup.md b/website/docs/features/path-cleanup.md index 2286ff963..2468c09d6 100644 --- a/website/docs/features/path-cleanup.md +++ b/website/docs/features/path-cleanup.md @@ -14,6 +14,8 @@ closed is treated as open, and a doubled path is cut twice. The path clean-up tools fix this on the selected workpieces. You find them in the **Object** menu and under **Clean Up Paths** in the canvas context menu. Each tool is a single undo step. +![The Close Paths dialog](/screenshots/main-path-cleanup.webp) + ## Close Paths **Close Paths…** closes every open path whose start and end point are closer than the tolerance you @@ -36,7 +38,7 @@ regardless of their direction or start point. When you select several workpieces is an exact copy of another selected workpiece at the same position is removed too. Overlapping paths that only share part of their length are left alone. The -[Merge Lines](merge-lines) post-processor takes care of those when the job is generated. +[Merge Lines](./merge-lines.md) post-processor takes care of those when the job is generated. ## Break Apart @@ -44,10 +46,10 @@ Overlapping paths that only share part of their length are left alone. The included. This differs from **Split**, which keeps an island together with its holes. Path clean-up only works on imported and traced workpieces. Workpieces that come from a sketch are -edited in the [Sketcher](sketcher/index) instead. +edited in the [Sketcher](./sketcher/index.md) instead. ## Related Pages -- [Merge Lines](merge-lines) - Cutting overlapping segments only once -- [Canvas Tools](../ui/canvas-tools) - Selecting and deleting individual segments -- [Importing Files](../files/importing) - Bringing designs into Rayforge +- [Merge Lines](./merge-lines.md) - Cutting overlapping segments only once +- [Canvas Tools](../ui/canvas-tools.md) - Selecting and deleting individual segments +- [Importing Files](../files/importing.md) - Bringing designs into Rayforge diff --git a/website/docs/ui/canvas-tools.md b/website/docs/ui/canvas-tools.md index 73bf633c3..5507b3afa 100644 --- a/website/docs/ui/canvas-tools.md +++ b/website/docs/ui/canvas-tools.md @@ -108,7 +108,7 @@ and delete individual vector segments of the workpiece. To exit vector editing mode, click outside the workpiece or press Escape. To close gaps, join open pieces, delete duplicate paths or break a workpiece apart, use the -[Path Clean-Up](../features/path-cleanup) tools. +[Path Clean-Up](../features/path-cleanup.md) tools. --- diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/features/path-cleanup.md b/website/i18n/de/docusaurus-plugin-content-docs/current/features/path-cleanup.md index 8bfeff794..20585be1c 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/features/path-cleanup.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/features/path-cleanup.md @@ -10,6 +10,8 @@ Die Werkzeuge zum Bereinigen von Pfaden beheben das bei den ausgewählten Werkst im Menü **Objekt** und im Kontextmenü der Arbeitsfläche unter **Pfade bereinigen**. Jedes Werkzeug ist ein einzelner Rückgängig-Schritt. +![Der Dialog „Pfade schließen“](/screenshots/main-path-cleanup.webp) + ## Pfade schließen **Pfade schließen…** schließt jeden offenen Pfad, dessen Anfangs- und Endpunkt näher beieinander @@ -34,7 +36,8 @@ ein Werkstück entfernt, das eine exakte Kopie eines anderen ausgewählten Werks Position ist. Überlappende Pfade, die nur einen Teil ihrer Länge gemeinsam haben, bleiben erhalten. Um diese -kümmert sich der Postprozessor [Linien zusammenführen](merge-lines), wenn der Auftrag erzeugt wird. +kümmert sich der Postprozessor [Linien zusammenführen](./merge-lines.md), wenn der Auftrag erzeugt +wird. ## Zerlegen @@ -42,10 +45,10 @@ kümmert sich der Postprozessor [Linien zusammenführen](merge-lines), wenn der offener Pfade. Das unterscheidet sich von **Teilen**, das eine Insel mit ihren Löchern zusammenhält. Das Bereinigen von Pfaden funktioniert nur bei importierten und nachgezeichneten Werkstücken. -Werkstücke aus einer Skizze bearbeitest du stattdessen im [Sketcher](sketcher/index). +Werkstücke aus einer Skizze bearbeitest du stattdessen im [Sketcher](./sketcher/index.md). ## Verwandte Seiten -- [Linien zusammenführen](merge-lines) - Überlappende Segmente nur einmal schneiden -- [Canvas-Werkzeuge](../ui/canvas-tools) - Einzelne Segmente auswählen und löschen -- [Dateien importieren](../files/importing) - Designs in Rayforge laden +- [Linien zusammenführen](./merge-lines.md) - Überlappende Segmente nur einmal schneiden +- [Canvas-Werkzeuge](../ui/canvas-tools.md) - Einzelne Segmente auswählen und löschen +- [Dateien importieren](../files/importing.md) - Designs in Rayforge laden diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/ui/canvas-tools.md b/website/i18n/de/docusaurus-plugin-content-docs/current/ui/canvas-tools.md index 4f725fa94..467296171 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/ui/canvas-tools.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/ui/canvas-tools.md @@ -167,7 +167,7 @@ Um den Vektorbearbeitungsmodus zu verlassen, klicke außerhalb des Werkstücks o Escape. Um Lücken zu schließen, offene Teile zu verbinden, doppelte Pfade zu löschen oder ein Werkstück zu -zerlegen, verwende die Werkzeuge unter [Pfade bereinigen](../features/path-cleanup). +zerlegen, verwende die Werkzeuge unter [Pfade bereinigen](../features/path-cleanup.md). --- diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/features/path-cleanup.md b/website/i18n/es/docusaurus-plugin-content-docs/current/features/path-cleanup.md index 5dada85f2..d1fcbad2c 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/features/path-cleanup.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/features/path-cleanup.md @@ -10,6 +10,8 @@ Las herramientas de limpieza de rutas corrigen esto en las piezas de trabajo sel encontrarás en el menú **Objeto** y en **Limpiar rutas** del menú contextual del lienzo. Cada herramienta es un solo paso de deshacer. +![El diálogo de cerrar rutas](/screenshots/main-path-cleanup.webp) + ## Cerrar rutas **Cerrar rutas…** cierra cada ruta abierta cuyo punto inicial y final están más cerca que la @@ -33,7 +35,7 @@ de trabajo, sin importar su dirección o punto inicial. Si seleccionas varias pi también se quita una pieza que sea una copia exacta de otra pieza seleccionada en la misma posición. Las rutas que solo se superponen en parte de su longitud se mantienen. De ellas se encarga el -posprocesador [Fusionar líneas](merge-lines) al generar el trabajo. +posprocesador [Fusionar líneas](./merge-lines.md) al generar el trabajo. ## Separar @@ -41,10 +43,10 @@ posprocesador [Fusionar líneas](merge-lines) al generar el trabajo. y las rutas abiertas. Esto es distinto de **Dividir**, que mantiene una isla junto con sus agujeros. La limpieza de rutas solo funciona en piezas de trabajo importadas y trazadas. Las piezas que vienen -de un boceto se editan en el [Sketcher](sketcher/index). +de un boceto se editan en el [Sketcher](./sketcher/index.md). ## Páginas relacionadas -- [Fusionar líneas](merge-lines) - Cortar los segmentos superpuestos una sola vez -- [Herramientas del lienzo](../ui/canvas-tools) - Seleccionar y eliminar segmentos individuales -- [Importar archivos](../files/importing) - Llevar diseños a Rayforge +- [Fusionar líneas](./merge-lines.md) - Cortar los segmentos superpuestos una sola vez +- [Herramientas del lienzo](../ui/canvas-tools.md) - Seleccionar y eliminar segmentos individuales +- [Importar archivos](../files/importing.md) - Llevar diseños a Rayforge diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/ui/canvas-tools.md b/website/i18n/es/docusaurus-plugin-content-docs/current/ui/canvas-tools.md index 45b9a3a55..5e5b97d34 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/ui/canvas-tools.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/ui/canvas-tools.md @@ -167,7 +167,7 @@ Para salir del modo de edición de vectores, haz clic fuera de la pieza de traba Escape. Para cerrar huecos, unir piezas abiertas, eliminar rutas duplicadas o separar una pieza de trabajo, -usa las herramientas de [Limpieza de rutas](../features/path-cleanup). +usa las herramientas de [Limpieza de rutas](../features/path-cleanup.md). --- diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/path-cleanup.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/path-cleanup.md index 4ba9f6cd9..ba547d4f6 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/path-cleanup.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/path-cleanup.md @@ -10,6 +10,8 @@ Les outils de nettoyage des chemins corrigent cela sur les pièces sélectionné dans le menu **Objet** et sous **Nettoyer les chemins** dans le menu contextuel du canevas. Chaque outil correspond à une seule étape d'annulation. +![La boîte de dialogue Fermer les chemins](/screenshots/main-path-cleanup.webp) + ## Fermer les chemins **Fermer les chemins…** ferme chaque chemin ouvert dont le point de départ et le point d'arrivée @@ -34,7 +36,8 @@ pièces, une pièce qui est la copie exacte d'une autre pièce sélectionnée à également retirée. Les chemins qui ne se chevauchent que sur une partie de leur longueur sont conservés. Le -post-processeur [Fusionner les lignes](merge-lines) s'en charge lors de la génération de la tâche. +post-processeur [Fusionner les lignes](./merge-lines.md) s'en charge lors de la génération de la +tâche. ## Décomposer @@ -42,10 +45,10 @@ post-processeur [Fusionner les lignes](merge-lines) s'en charge lors de la gén compris. C'est différent de **Scinder**, qui garde un îlot avec ses trous. Le nettoyage des chemins ne fonctionne que sur les pièces importées et vectorisées. Les pièces -issues d'une esquisse se modifient dans le [Sketcher](sketcher/index). +issues d'une esquisse se modifient dans le [Sketcher](./sketcher/index.md). ## Pages connexes -- [Fusionner les lignes](merge-lines) - Ne découper qu'une fois les segments qui se chevauchent -- [Outils du canevas](../ui/canvas-tools) - Sélectionner et supprimer des segments individuels -- [Importer des fichiers](../files/importing) - Importer des dessins dans Rayforge +- [Fusionner les lignes](./merge-lines.md) - Ne découper qu'une fois les segments qui se chevauchent +- [Outils du canevas](../ui/canvas-tools.md) - Sélectionner et supprimer des segments individuels +- [Importer des fichiers](../files/importing.md) - Importer des dessins dans Rayforge diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/ui/canvas-tools.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/ui/canvas-tools.md index 4fc699ba7..bca5b7210 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/ui/canvas-tools.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/ui/canvas-tools.md @@ -169,7 +169,7 @@ Pour quitter le mode d'édition vectorielle, cliquez en dehors de la pièce ou a Échap. Pour fermer des écarts, joindre des morceaux ouverts, supprimer des chemins en double ou décomposer -une pièce, utilisez les outils de [Nettoyage des chemins](../features/path-cleanup). +une pièce, utilisez les outils de [Nettoyage des chemins](../features/path-cleanup.md). --- diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/path-cleanup.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/path-cleanup.md index dae47055a..803b48b45 100644 --- a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/path-cleanup.md +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/path-cleanup.md @@ -8,6 +8,8 @@ पथ सफ़ाई टूल चुने गए वर्कपीस पर इसे ठीक करते हैं. ये **ऑब्जेक्ट** मेनू में और कैनवस के संदर्भ मेनू में **पथ साफ़ करें** के अंतर्गत मिलते हैं. हर टूल एक ही पूर्ववत चरण है. +![पथ बंद करें संवाद](/screenshots/main-path-cleanup.webp) + ## पथ बंद करें **पथ बंद करें…** हर उस खुले पथ को बंद करता है जिसके आरंभ और अंत बिंदु आपकी दी गई सहनशीलता से अधिक @@ -30,7 +32,7 @@ mm से 0.2 mm के बीच की सहनशीलता ठीक र स्थान पर किसी दूसरे चुने गए वर्कपीस की हूबहू प्रति है. जो पथ अपनी लंबाई के केवल एक हिस्से में ओवरलैप करते हैं, वे बने रहते हैं. जॉब बनाते समय -[रेखाएँ मर्ज करें](merge-lines) पोस्ट-प्रोसेसर उन्हें संभालता है. +[रेखाएँ मर्ज करें](./merge-lines.md) पोस्ट-प्रोसेसर उन्हें संभालता है. ## अलग-अलग करें @@ -38,10 +40,10 @@ mm से 0.2 mm के बीच की सहनशीलता ठीक र **विभाजित करें** से अलग है, जो किसी द्वीप को उसके छेदों के साथ एक रखता है. पथ सफ़ाई केवल आयात किए गए और ट्रेस किए गए वर्कपीस पर काम करती है. स्केच से बने वर्कपीस को -[स्केचर](sketcher/index) में संपादित करें. +[स्केचर](./sketcher/index.md) में संपादित करें. ## संबंधित पृष्ठ -- [रेखाएँ मर्ज करें](merge-lines) - ओवरलैप होने वाले खंडों को केवल एक बार काटना -- [कैनवस उपकरण](../ui/canvas-tools) - अलग-अलग खंड चुनना और हटाना -- [फ़ाइलें आयात करना](../files/importing) - Rayforge में डिज़ाइन लाना +- [रेखाएँ मर्ज करें](./merge-lines.md) - ओवरलैप होने वाले खंडों को केवल एक बार काटना +- [कैनवस उपकरण](../ui/canvas-tools.md) - अलग-अलग खंड चुनना और हटाना +- [फ़ाइलें आयात करना](../files/importing.md) - Rayforge में डिज़ाइन लाना diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/ui/canvas-tools.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/ui/canvas-tools.md index b9521d394..718774312 100644 --- a/website/i18n/hi/docusaurus-plugin-content-docs/current/ui/canvas-tools.md +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/ui/canvas-tools.md @@ -109,7 +109,7 @@ description: वेक्टर संपादन मोड से बाहर निकलने के लिए, वर्कपीस के बाहर क्लिक करें या Escape दबाएँ. अंतराल बंद करने, खुले टुकड़ों को जोड़ने, डुप्लिकेट पथ हटाने या किसी वर्कपीस को अलग-अलग करने के लिए -[पथ सफ़ाई](../features/path-cleanup) टूल का उपयोग करें. +[पथ सफ़ाई](../features/path-cleanup.md) टूल का उपयोग करें. --- diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/path-cleanup.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/path-cleanup.md index 22771fbcb..9e0893efc 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/path-cleanup.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/path-cleanup.md @@ -10,6 +10,8 @@ As ferramentas de limpeza de caminhos corrigem isso nas peças de trabalho selec encontra no menu **Objeto** e em **Limpar caminhos** no menu de contexto da tela. Cada ferramenta é um único passo de desfazer. +![O diálogo de fechar caminhos](/screenshots/main-path-cleanup.webp) + ## Fechar caminhos **Fechar caminhos…** fecha cada caminho aberto cujos pontos inicial e final estão mais próximos do @@ -34,7 +36,7 @@ trabalho, uma peça que seja cópia exata de outra peça selecionada na mesma po removida. Caminhos que se sobrepõem apenas em parte do comprimento são mantidos. O pós-processador -[Mesclar linhas](merge-lines) cuida deles quando o trabalho é gerado. +[Mesclar linhas](./merge-lines.md) cuida deles quando o trabalho é gerado. ## Separar @@ -42,10 +44,10 @@ Caminhos que se sobrepõem apenas em parte do comprimento são mantidos. O pós- caminhos abertos. Isso é diferente de **Dividir**, que mantém uma ilha junto com seus furos. A limpeza de caminhos só funciona em peças de trabalho importadas e vetorizadas. Peças que vêm de um -esboço são editadas no [Sketcher](sketcher/index). +esboço são editadas no [Sketcher](./sketcher/index.md). ## Páginas relacionadas -- [Mesclar linhas](merge-lines) - Cortar segmentos sobrepostos apenas uma vez -- [Ferramentas de tela](../ui/canvas-tools) - Selecionar e excluir segmentos individuais -- [Importando arquivos](../files/importing) - Trazer desenhos para o Rayforge +- [Mesclar linhas](./merge-lines.md) - Cortar segmentos sobrepostos apenas uma vez +- [Ferramentas de tela](../ui/canvas-tools.md) - Selecionar e excluir segmentos individuais +- [Importando arquivos](../files/importing.md) - Trazer desenhos para o Rayforge diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/ui/canvas-tools.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/ui/canvas-tools.md index db63200b1..10245e011 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/ui/canvas-tools.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/ui/canvas-tools.md @@ -167,7 +167,7 @@ Para sair do modo de edição de vetores, clique fora da peça de trabalho ou pr Escape. Para fechar lacunas, unir partes abertas, excluir caminhos duplicados ou separar uma peça de -trabalho, use as ferramentas de [Limpeza de caminhos](../features/path-cleanup). +trabalho, use as ferramentas de [Limpeza de caminhos](../features/path-cleanup.md). --- diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/path-cleanup.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/path-cleanup.md index 087cd40a1..a87fc20a9 100644 --- a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/path-cleanup.md +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/path-cleanup.md @@ -9,6 +9,8 @@ в контекстному меню полотна в розділі **Очистити шляхи**. Кожен інструмент — це один крок скасування. +![Діалогове вікно «Замкнути шляхи»](/screenshots/main-path-cleanup.webp) + ## Замкнути шляхи **Замкнути шляхи…** замикає кожен відкритий шлях, у якого початкова й кінцева точки ближчі за @@ -31,7 +33,7 @@ яка є точною копією іншої вибраної деталі в тому самому місці, також видаляється. Шляхи, що перекриваються лише частиною своєї довжини, залишаються. Ними опікується постпроцесор -[Об'єднання ліній](merge-lines) під час створення завдання. +[Об'єднання ліній](./merge-lines.md) під час створення завдання. ## Розібрати на частини @@ -39,10 +41,10 @@ відкритими шляхами. Це відрізняється від **Розділити**, яке тримає острів разом з його отворами. Очищення шляхів працює лише з імпортованими та трасованими робочими деталями. Деталі, створені зі -скетчу, редагуйте у [Sketcher](sketcher/index). +скетчу, редагуйте у [Sketcher](./sketcher/index.md). ## Пов'язані сторінки -- [Об'єднання ліній](merge-lines) - Різати сегменти, що перекриваються, лише один раз -- [Інструменти полотна](../ui/canvas-tools) - Вибір і видалення окремих сегментів -- [Імпортування файлів](../files/importing) - Завантаження дизайнів у Rayforge +- [Об'єднання ліній](./merge-lines.md) - Різати сегменти, що перекриваються, лише один раз +- [Інструменти полотна](../ui/canvas-tools.md) - Вибір і видалення окремих сегментів +- [Імпортування файлів](../files/importing.md) - Завантаження дизайнів у Rayforge diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/ui/canvas-tools.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/ui/canvas-tools.md index 83cae063b..fd5f6050a 100644 --- a/website/i18n/uk/docusaurus-plugin-content-docs/current/ui/canvas-tools.md +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/ui/canvas-tools.md @@ -104,7 +104,7 @@ Escape. Щоб закрити проміжки, з'єднати відкриті частини, видалити дублікати шляхів або розібрати робочу -деталь на частини, скористайтеся інструментами [Очищення шляхів](../features/path-cleanup). +деталь на частини, скористайтеся інструментами [Очищення шляхів](../features/path-cleanup.md). --- diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/path-cleanup.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/path-cleanup.md index 604717dfc..0bd793333 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/path-cleanup.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/path-cleanup.md @@ -4,6 +4,8 @@ 路径清理工具可以修复所选工件中的这些问题。您可以在**对象**菜单中,以及画布右键菜单的**清理路径**下找到它们。每个工具都是一个撤销步骤。 +![闭合路径对话框](/screenshots/main-path-cleanup.webp) + ## 闭合路径 **闭合路径…**会闭合所有起点与终点距离小于您输入的公差的开放路径。缺口由两个现有点之间的一条直线连接,因此形状不会移动,也不会改变大小。 @@ -19,16 +21,16 @@ mm 之间的公差即可。 **删除重复项**会删除与同一工件中另一条路径完全重合的路径,无论其方向或起点如何。如果您选择了多个工件,与另一个所选工件位置相同且完全相同的工件也会被删除。 -只有部分长度重叠的路径会被保留。生成作业时,[合并线条](merge-lines)后处理器会处理这些路径。 +只有部分长度重叠的路径会被保留。生成作业时,[合并线条](./merge-lines.md)后处理器会处理这些路径。 ## 打散 **打散**会把工件的每条路径都变成一个独立的工件,包括孔和开放路径。这与**分割**不同,分割会把一个岛与它的孔保留在一起。 -路径清理只适用于导入和描摹得到的工件。来自草图的工件请在[草图编辑器](sketcher/index)中编辑。 +路径清理只适用于导入和描摹得到的工件。来自草图的工件请在[草图编辑器](./sketcher/index.md)中编辑。 ## 相关页面 -- [合并线条](merge-lines) - 重叠的线段只切割一次 -- [画布工具](../ui/canvas-tools) - 选择和删除单个线段 -- [导入文件](../files/importing) - 将设计导入 Rayforge +- [合并线条](./merge-lines.md) - 重叠的线段只切割一次 +- [画布工具](../ui/canvas-tools.md) - 选择和删除单个线段 +- [导入文件](../files/importing.md) - 将设计导入 Rayforge diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/ui/canvas-tools.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/ui/canvas-tools.md index efa60de09..16a451793 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/ui/canvas-tools.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/ui/canvas-tools.md @@ -159,7 +159,7 @@ 要退出矢量编辑模式,请点击工件外部或按 Escape。 -要闭合缺口、连接开放的片段、删除重复路径或将工件打散,请使用[清理路径](../features/path-cleanup)工具。 +要闭合缺口、连接开放的片段、删除重复路径或将工件打散,请使用[清理路径](../features/path-cleanup.md)工具。 --- From 326776a6afcef5d2f98835e1058147a644cfc505 Mon Sep 17 00:00:00 2001 From: Samuel Abels Date: Sun, 11 Oct 2026 23:25:36 +0200 Subject: [PATCH 044/104] docs: document reload and relink of imported sources Add a Keeping Imported Files in Sync section to the importing docs covering the watch preference, the reload dialog and manual reload and relink, and mention the watch preference on the settings page. Include a screenshot target and capture for the new dialog, and translate both pages for all supported website languages. --- scripts/screenshot/cli.py | 1 + scripts/screenshot/source_changed_dialog.py | 68 ++++++++++++++++++ website/docs/files/importing.md | 41 +++++++++++ website/docs/ui/settings.md | 4 ++ .../current/files/importing.md | 48 +++++++++++++ .../current/ui/settings.md | 5 ++ .../current/files/importing.md | 47 ++++++++++++ .../current/ui/settings.md | 5 ++ .../current/files/importing.md | 48 +++++++++++++ .../current/ui/settings.md | 5 ++ .../current/files/importing.md | 42 +++++++++++ .../current/ui/settings.md | 5 ++ .../current/files/importing.md | 46 ++++++++++++ .../current/ui/settings.md | 5 ++ .../current/files/importing.md | 43 +++++++++++ .../current/ui/settings.md | 5 ++ .../current/files/importing.md | 32 +++++++++ .../current/ui/settings.md | 4 ++ .../screenshots/source-changed-dialog.webp | Bin 0 -> 35814 bytes 19 files changed, 454 insertions(+) create mode 100644 scripts/screenshot/source_changed_dialog.py create mode 100644 website/static/screenshots/source-changed-dialog.webp diff --git a/scripts/screenshot/cli.py b/scripts/screenshot/cli.py index 880a53fd8..1afeaa3ae 100755 --- a/scripts/screenshot/cli.py +++ b/scripts/screenshot/cli.py @@ -51,6 +51,7 @@ "config-wizard:review": "config_wizard", "config-wizard:rotary": "config_wizard", "import-dialog": "import_dialog", + "source-changed-dialog": "source_changed_dialog", "machine-settings:general": "machine_settings_general", "machine-settings:hardware": "machine_settings_hardware", "machine-settings:advanced": "machine_settings_advanced", diff --git a/scripts/screenshot/source_changed_dialog.py b/scripts/screenshot/source_changed_dialog.py new file mode 100644 index 000000000..004e43e7d --- /dev/null +++ b/scripts/screenshot/source_changed_dialog.py @@ -0,0 +1,68 @@ +""" +Screenshot: Source changed dialog. + +Usage: pixi run screenshot source-changed-dialog +""" + +import logging +import time +from pathlib import Path + +from utils import ( + restore_config, + run_on_main_thread, + set_window_size, + take_screenshot, + wait_for_settled, +) + +from rayforge.uiscript import app, win + +logger = logging.getLogger(__name__) + +SOURCE_FILE = Path("~/designs/badge.svg").expanduser() + + +@restore_config +def main(): + set_window_size(win, 1200, 900) + + logger.info("Waiting for document to settle...") + if not wait_for_settled(win, timeout=10): + logger.error("Document did not settle in time") + app.quit_idle() + return + + from rayforge.core.source_asset import SourceAsset + from rayforge.image import SVG_RENDERER + from rayforge.ui_gtk.doceditor.source_changed_dialog import ( + SourceChangedDialog, + ) + + def open_dialog(): + asset = SourceAsset( + source_file=SOURCE_FILE, + original_data=b"", + renderer=SVG_RENDERER, + ) + dialog = SourceChangedDialog([asset]) + dialog.present(win) + return dialog + + dialog = run_on_main_thread(open_dialog) + + time.sleep(1.0) + + logger.info("Taking screenshot: source-changed-dialog.webp") + take_screenshot() + + time.sleep(0.25) + + def close_dialog(): + dialog.close() + + run_on_main_thread(close_dialog) + app.quit_idle() + + +main() diff --git a/website/docs/files/importing.md b/website/docs/files/importing.md index 9d2116c63..87c2cff4c 100644 --- a/website/docs/files/importing.md +++ b/website/docs/files/importing.md @@ -115,6 +115,47 @@ The resize notification appears as a toast message: This ensures your designs always fit within your machine's capabilities while giving you the flexibility to restore the original size if needed. +### Keeping Imported Files in Sync + +When you import a file, Rayforge keeps a link to it on disk. If the file changes later — for example +after editing and re-saving it in Inkscape or your CAD program — Rayforge offers to reload the +imported content, so your project does not keep outdated geometry. + +![The Imported File Changed dialog](/screenshots/source-changed-dialog.webp) + +Watching is controlled by the **Watch imported files** option in Settings → General. When it is on +(the default), Rayforge checks the linked files while the project is open — including right after +opening it — and shows a dialog for each file that changed on disk: + +- **Reload** replaces the imported content with the new version. Position, size and the settings of + the affected workpieces are kept, and the whole reload is a single undo step. +- **Ignore** keeps the current content. You will be asked again the next time the file changes. +- Enable **Always reload this file without asking** to reload that file automatically from now on. + Files marked this way are reloaded without asking. + +When several linked files changed at once, the dialog lists them and lets you choose which ones to +reload. + + +:::note[Reload Adjusts the Document] +Reloading brings the document back in line with the file: elements that were added to it appear in +the project, elements that are no longer in it are removed, and holding tabs that end up more than +1 mm away from the new outline are dropped. Elements you edited in Rayforge are left unchanged. A +notification summarizes what the reload changed. +::: + + +### Manual Reload and Relinking + +You can also reload or relink manually at any time via **File → Reload from Disk** and **File → +Relink Source File…**, or through the context menu of the canvas selection, the layer list, or the +asset browser. **Reload from Disk** re-reads the files behind the selected workpieces. When the +linked file cannot be found, Rayforge suggests relinking it instead. + +**Relink Source File…** points an imported source at a different file. This is useful when a file +was moved or renamed, or when a project is opened on another computer and the linked file is +missing. The workpieces keep their position and settings and show the content of the new file. + --- ## SVG Import diff --git a/website/docs/ui/settings.md b/website/docs/ui/settings.md index deb381e78..7d93f044d 100644 --- a/website/docs/ui/settings.md +++ b/website/docs/ui/settings.md @@ -36,6 +36,10 @@ recalculation manually via the toolbar button instead. Rayforge can **Check for updates** automatically on startup. When enabled, you will be notified when a new version is available. +With **Watch imported files** enabled, Rayforge monitors the files behind imported workpieces and +offers to reload them when they change on disk. See +[Keeping Imported Files in Sync](../files/importing.md#keeping-imported-files-in-sync) for details. + You can also configure **Startup behavior** — start with an empty workspace, reopen your last project, or always open a specific project file. Note that files specified on the command line will always override these settings. diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/files/importing.md b/website/i18n/de/docusaurus-plugin-content-docs/current/files/importing.md index f9b57daa4..b0ca39161 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/files/importing.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/files/importing.md @@ -113,6 +113,54 @@ Die Größenänderungs-Benachrichtigung erscheint als Toast-Meldung: Dies stellt sicher, dass deine Designs immer in die Fähigkeiten deiner Maschine passen, während du die Flexibilität hast, die Originalgröße bei Bedarf wiederherzustellen. +### Importierte Dateien synchron halten + +Beim Importieren behält Rayforge eine Verknüpfung zur Datei auf der Festplatte. Ändert sich die +Datei später — etwa nach dem Bearbeiten und erneuten Speichern in Inkscape oder deinem CAD-Programm +— bietet Rayforge an, den importierten Inhalt neu zu laden, damit dein Projekt nicht veraltete +Geometrie behält. + +![Der Dialog „Importierte Datei geändert“](/screenshots/source-changed-dialog.webp) + +Gesteuert wird das über die Option **Importierte Dateien beobachten** in den Einstellungen unter +Allgemein. Ist sie aktiviert (die Voreinstellung), prüft Rayforge die verknüpften Dateien, solange +das Projekt geöffnet ist — auch direkt nach dem Öffnen — und zeigt für jede geänderte Datei einen +Dialog an: + +- **Neu laden** ersetzt den importierten Inhalt durch die neue Version. Position, Größe und die + Einstellungen der betroffenen Werkstücke bleiben erhalten, und das gesamte Neuladen ist ein + einziger Undo-Schritt. +- **Ignorieren** behält den aktuellen Inhalt. Beim nächsten Ändern der Datei wirst du erneut + gefragt. +- Aktiviere **Diese Datei immer ohne Nachfrage neu laden**, um diese Datei künftig automatisch neu + zu laden. So markierte Dateien werden ohne Nachfrage neu geladen. + +Haben sich mehrere verknüpfte Dateien gleichzeitig geändert, listet der Dialog alle auf, und du +wählst, welche neu geladen werden sollen. + + +:::note[Neu laden passt das Dokument an] +Beim Neuladen wird das Dokument wieder mit der Datei abgeglichen: Elemente, die neu dazugekommen +sind, erscheinen im Projekt, Elemente, die nicht mehr in der Datei sind, werden entfernt, und +Halte-Laschen, die mehr als 1 mm von der neuen Kontur entfernt liegen, werden gelöscht. In Rayforge +bearbeitete Elemente bleiben unverändert. Eine Benachrichtigung fasst zusammen, was das Neuladen +geändert hat. +::: + + +### Manuelles Neuladen und Neuverknüpfen + +Du kannst jederzeit auch manuell neu laden oder neu verknüpfen — über **Datei → Von Festplatte neu +laden** und **Datei → Quelldatei neu verknüpfen …** oder über das Kontextmenü der Auswahl auf der +Zeichenfläche, der Ebenenliste oder des Asset-Browsers. **Von Festplatte neu laden** liest die +Dateien hinter den ausgewählten Werkstücken erneut ein. Ist die verknüpfte Datei nicht auffindbar, +schlägt Rayforge stattdessen das Neuverknüpfen vor. + +**Quelldatei neu verknüpfen …** weist eine importierte Quelle einer anderen Datei zu. Das ist +nützlich, wenn eine Datei verschoben oder umbenannt wurde oder wenn ein Projekt auf einem anderen +Computer geöffnet wird und die verknüpfte Datei dort fehlt. Die Werkstücke behalten Position und +Einstellungen und zeigen den Inhalt der neuen Datei. + --- ## SVG-Import diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/ui/settings.md b/website/i18n/de/docusaurus-plugin-content-docs/current/ui/settings.md index 198c9f79c..d748d882c 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/ui/settings.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/ui/settings.md @@ -32,6 +32,11 @@ Symbolleisten-Schaltfläche auslösen. Rayforge kann beim Start **automatisch nach Updates suchen**. Wenn aktiviert, wirst du benachrichtigt, wenn eine neue Version verfügbar ist. +Ist **Importierte Dateien beobachten** aktiviert, überwacht Rayforge die Dateien hinter importierten +Werkstücken und bietet an, sie neu zu laden, wenn sie sich auf der Festplatte ändern. Details +findest du unter +[Importierte Dateien synchron halten](../files/importing.md#importierte-dateien-synchron-halten). + Du kannst auch das **Startverhalten** konfigurieren — mit einem leeren Arbeitsbereich starten, das letzte Projekt erneut öffnen oder immer eine bestimmte Projektdatei öffnen. Beachte, dass auf der Kommandozeile angegebene Dateien diese Einstellungen immer außer Kraft setzen. diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/files/importing.md b/website/i18n/es/docusaurus-plugin-content-docs/current/files/importing.md index 7d8ed5725..477280ee0 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/files/importing.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/files/importing.md @@ -111,6 +111,53 @@ La notificación de redimensionado aparece como un mensaje emergente: Esto garantiza que tus diseños siempre se ajusten a las capacidades de tu máquina, dándote la flexibilidad de restaurar el tamaño original si es necesario. +### Mantener los archivos importados sincronizados + +Al importar un archivo, Rayforge conserva un enlace a este en el disco. Si el archivo cambia más +tarde — por ejemplo después de editarlo y guardarlo de nuevo en Inkscape o en tu programa de CAD — +Rayforge ofrece recargar el contenido importado, para que tu proyecto no conserve geometría +obsoleta. + +![El diálogo de archivo importado modificado](/screenshots/source-changed-dialog.webp) + +Esta función se controla con la opción **Vigilar archivos importados** en Configuración → General. +Cuando está activada (el valor predeterminado), Rayforge comprueba los archivos enlazados mientras +el proyecto está abierto — también justo después de abrirlo — y muestra un diálogo para cada archivo +que haya cambiado en el disco: + +- **Recargar** sustituye el contenido importado por la nueva versión. La posición, el tamaño y la + configuración de las piezas de trabajo afectadas se conservan, y toda la recarga es un único paso + de deshacer. +- **Ignorar** conserva el contenido actual. Se te preguntará de nuevo la próxima vez que cambie el + archivo. +- Activa **Recargar siempre este archivo sin preguntar** para recargar ese archivo automáticamente + de ahora en adelante. Los archivos marcados así se recargan sin preguntar. + +Cuando varios archivos enlazados han cambiado a la vez, el diálogo los enumera y te permite elegir +cuáles recargar. + + +:::note[La recarga ajusta el documento] +Al recargar, el documento vuelve a coincidir con el archivo: los elementos que se le añadieron +aparecen en el proyecto, los elementos que ya no están en él se eliminan, y las pestañas de +sujeción que queden a más de 1 mm del nuevo contorno se descartan. Los elementos que editaste en +Rayforge se dejan sin cambios. Una notificación resume lo que cambió la recarga. +::: + + +### Recarga y reenlace manuales + +También puedes recargar o reenlazar manualmente en cualquier momento mediante **Archivo → Recargar +desde el disco** y **Archivo → Reenlazar archivo de origen…**, o a través del menú contextual de la +selección en el lienzo, de la lista de capas o del explorador de activos. **Recargar desde el +disco** vuelve a leer los archivos detrás de las piezas de trabajo seleccionadas. Cuando no se puede +encontrar el archivo enlazado, Rayforge sugiere reenlazarlo en su lugar. + +**Reenlazar archivo de origen…** apunta una fuente importada a un archivo diferente. Esto es útil +cuando un archivo se movió o renombró, o cuando un proyecto se abre en otro ordenador y falta el +archivo enlazado. Las piezas de trabajo conservan su posición y configuración y muestran el +contenido del nuevo archivo. + --- ## Importación SVG diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/ui/settings.md b/website/i18n/es/docusaurus-plugin-content-docs/current/ui/settings.md index ceeaac70e..1fb0ee557 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/ui/settings.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/ui/settings.md @@ -30,6 +30,11 @@ operaciones** y activar el recálculo manualmente vía el botón de la barra de Rayforge puede **Buscar actualizaciones** automáticamente al inicio. Cuando está habilitado, se te notificará cuando haya una nueva versión disponible. +Con **Vigilar archivos importados** activado, Rayforge supervisa los archivos detrás de las piezas +de trabajo importadas y ofrece recargarlas cuando cambian en el disco. Consulta +[Mantener los archivos importados sincronizados](../files/importing.md#mantener-los-archivos-importados-sincronizados) +para más detalles. + También puedes configurar el **Comportamiento de inicio** — iniciar con un espacio de trabajo vacío, reabrir el último proyecto o abrir siempre un archivo de proyecto específico. Ten en cuenta que los archivos especificados en la línea de comandos siempre anularán estos ajustes. diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/files/importing.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/files/importing.md index e86f94bee..ec51e43a7 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/files/importing.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/files/importing.md @@ -114,6 +114,54 @@ La notification de redimensionnement apparaît sous forme de message toast : Cela garantit que tes dessins s'adaptent toujours aux capacités de ta machine tout en t'offrant la flexibilité de restaurer la taille d'origine si nécessaire. +### Garder les fichiers importés synchronisés + +Lors de l'importation, Rayforge conserve un lien vers le fichier sur le disque. Si le fichier change +plus tard — par exemple après l'avoir modifié et réenregistré dans Inkscape ou dans ton logiciel de +CAO — Rayforge propose de recharger le contenu importé, afin que ton projet ne conserve pas une +géométrie obsolète. + +![La boîte de dialogue « Fichier importé modifié »](/screenshots/source-changed-dialog.webp) + +Cette fonction est contrôlée par l'option **Surveiller les fichiers importés** dans Paramètres → +Général. Lorsqu'elle est activée (valeur par défaut), Rayforge vérifie les fichiers liés tant que le +projet est ouvert — y compris juste après son ouverture — et affiche une boîte de dialogue pour +chaque fichier modifié sur le disque : + +- **Recharger** remplace le contenu importé par la nouvelle version. La position, la taille et les + réglages des pièces concernées sont conservés, et l'ensemble du rechargement constitue une seule + étape d'annulation. +- **Ignorer** conserve le contenu actuel. Tu seras à nouveau consulté la prochaine fois que le + fichier changera. +- Active **Toujours recharger ce fichier sans demander** pour recharger ce fichier automatiquement à + partir de maintenant. Les fichiers ainsi marqués sont rechargés sans demander. + +Lorsque plusieurs fichiers liés ont changé en même temps, la boîte de dialogue les liste et te +permet de choisir ceux à recharger. + + +:::note[Le rechargement ajuste le document] +Le rechargement remet le document en accord avec le fichier : les éléments qui y ont été ajoutés +apparaissent dans le projet, les éléments qui n'en font plus partie sont supprimés, et les ponts de +maintien qui se retrouvent à plus de 1 mm du nouveau contour sont abandonnés. Les éléments que tu +as modifiés dans Rayforge restent inchangés. Une notification résume ce que le rechargement a +modifié. +::: + + +### Rechargement et reliaison manuels + +Tu peux aussi recharger ou relier manuellement à tout moment via **Fichier → Recharger depuis le +disque** et **Fichier → Relier le fichier source…**, ou via le menu contextuel de la sélection sur +le canevas, de la liste des calques ou du navigateur d'actifs. **Recharger depuis le disque** relit +les fichiers derrière les pièces sélectionnées. Lorsque le fichier lié est introuvable, Rayforge +propose de le relier à la place. + +**Relier le fichier source…** fait pointer une source importée vers un autre fichier. C'est utile +lorsqu'un fichier a été déplacé ou renommé, ou lorsqu'un projet est ouvert sur un autre ordinateur +et que le fichier lié est absent. Les pièces conservent leur position et leurs réglages et affichent +le contenu du nouveau fichier. + --- ## Importation SVG diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/ui/settings.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/ui/settings.md index b64779948..522631e50 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/ui/settings.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/ui/settings.md @@ -32,6 +32,11 @@ la barre d'outils. Rayforge peut **Vérifier les mises à jour** automatiquement au démarrage. Lorsqu'activé, tu seras informé lorsqu'une nouvelle version est disponible. +Lorsque **Surveiller les fichiers importés** est activé, Rayforge surveille les fichiers derrière +les pièces importées et propose de les recharger lorsqu'ils changent sur le disque. Voir +[Garder les fichiers importés synchronisés](../files/importing.md#garder-les-fichiers-importés-synchronisés) +pour plus de détails. + Tu peux aussi configurer le **Comportement au démarrage** — démarrer avec un espace de travail vide, rouvrir le dernier projet ou toujours ouvrir un fichier de projet spécifique. Note que les fichiers spécifiés en ligne de commande remplaceront toujours ces paramètres. diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/files/importing.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/files/importing.md index d213e05d6..e4523201d 100644 --- a/website/i18n/hi/docusaurus-plugin-content-docs/current/files/importing.md +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/files/importing.md @@ -115,6 +115,48 @@ rayforge file1.svg file2.dxf यह सुनिश्चित करता है कि आपके डिज़ाइन हमेशा आपकी मशीन की क्षमताओं के भीतर फ़िट हों, साथ ही आवश्यकता पड़ने पर मूल आकार पुनर्स्थापित करने का लचीलापन भी देते हैं. +### आयात की गई फ़ाइलों को सिंक में रखना + +फ़ाइल आयात करते समय Rayforge डिस्क पर उसका एक लिंक बनाए रखता है. यदि फ़ाइल बाद में बदलती है — उदाहरण +के लिए Inkscape या अपने CAD प्रोग्राम में उसे संपादित करके दोबारा सहेजने के बाद — तो Rayforge आयातित +सामग्री को फिर से लोड करने का प्रस्ताव देता है, ताकि आपके प्रोजेक्ट में पुरानी ज्यामिति न रहे. + +![आयातित फ़ाइल बदली गई डायलॉग](/screenshots/source-changed-dialog.webp) + +यह सुविधा Settings → General में **Watch imported files** विकल्प से नियंत्रित होती है. जब यह चालू हो +(डिफ़ॉल्ट), तो Rayforge प्रोजेक्ट खुले रहने के दौरान लिंक की गई फ़ाइलों की जाँच करता है — इसे खोलने +के तुरंत बाद भी — और डिस्क पर बदली गई प्रत्येक फ़ाइल के लिए एक डायलॉग दिखाता है: + +- **Reload** आयातित सामग्री को नए संस्करण से बदल देता है. प्रभावित वर्कपीस की स्थिति, आकार और + सेटिंग्स बनी रहती हैं, और पूरा रीलोड एक ही अंडू चरण होता है. +- **Ignore** वर्तमान सामग्री को बनाए रखता है. अगली बार फ़ाइल बदलने पर आपसे फिर पूछा जाएगा. +- **Always reload this file without asking** चालू करें ताकि यह फ़ाइल आगे से स्वतः फिर से लोड हो. इस + तरह चिह्नित फ़ाइलें बिना पूछे फिर से लोड हो जाती हैं. + +जब कई लिंक की गई फ़ाइलें एक साथ बदल गई हों, तो डायलॉग उन्हें सूचीबद्ध करता है और आप चुन सकते हैं कि +किन्हें फिर से लोड करना है. + + +:::note[रीलोड दस्तावेज़ को समायोजित करता है] +रीलोड करने पर दस्तावेज़ फ़ाइल के अनुरूप हो जाता है: जो तत्व उसमें जोड़े गए थे वे प्रोजेक्ट में +दिखाई देते हैं, जो तत्व अब उसमें नहीं हैं वे हटा दिए जाते हैं, और जो होल्डिंग टैब नए आउटलाइन से +1 mm से अधिक दूर हो जाते हैं उन्हें हटा दिया जाता है. Rayforge में आपके द्वारा संपादित तत्व +अपरिवर्तित रहते हैं. एक सूचना बताती है कि रीलोड ने क्या बदला. +::: + + +### मैन्युअल रीलोड और रीलिंक + +आप किसी भी समय मैन्युअल रूप से भी रीलोड या रीलिंक कर सकते हैं — **File → Reload from Disk** और +**File → Relink Source File…** के माध्यम से, या कैनवस चयन, लेयर सूची, या एसेट ब्राउज़र के संदर्भ +मेनू के माध्यम से. **Reload from Disk** चयनित वर्कपीस के पीछे की फ़ाइलों को दोबारा पढ़ता है. जब लिंक +की गई फ़ाइल नहीं मिलती, तो Rayforge इसके बजाय रीलिंक करने का सुझाव देता है. + +**Relink Source File…** आयातित स्रोत को किसी दूसरी फ़ाइल की ओर इंगित करता है. यह तब उपयोगी होता है +जब कोई फ़ाइल स्थानांतरित या नाम बदली गई हो, या जब कोई प्रोजेक्ट किसी अन्य कंप्यूटर पर खोला जाए और +लिंक की गई फ़ाइल वहाँ न हो. वर्कपीस अपनी स्थिति और सेटिंग्स बनाए रखते हैं और नई फ़ाइल की सामग्री +दिखाते हैं. + --- ## SVG आयात diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/ui/settings.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/ui/settings.md index 863079a8a..13a262704 100644 --- a/website/i18n/hi/docusaurus-plugin-content-docs/current/ui/settings.md +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/ui/settings.md @@ -36,6 +36,11 @@ Settings** द्वारा खोलें या ctrl+comma द Rayforge स्टार्टअप पर स्वतः **अपडेट के लिए जाँच** कर सकता है. सक्षम होने पर, नया संस्करण उपलब्ध होने पर आपको सूचित किया जाएगा. +**Watch imported files** चालू होने पर, Rayforge आयातित वर्कपीस के पीछे की फ़ाइलों पर नज़र रखता है और +जब वे डिस्क पर बदलती हैं तो उन्हें फिर से लोड करने का प्रस्ताव देता है. विवरण के लिए +[आयात की गई फ़ाइलों को सिंक में रखना](../files/importing.md#आयात-की-गई-फ़ाइलों-को-सिंक-में-रखना) +देखें. + आप **स्टार्टअप व्यवहार** भी कॉन्फ़िगर कर सकते हैं — रिक्त कार्यक्षेत्र के साथ शुरू करें, अपनी अंतिम परियोजना फिर से खोलें, या हमेशा एक विशिष्ट परियोजना फ़ाइल खोलें. ध्यान दें कि कमांड लाइन पर निर्दिष्ट फ़ाइलें हमेशा इन सेटिंग्स को अध्यारोहित करेंगी. diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/files/importing.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/files/importing.md index ed7af3f52..abcf1268e 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/files/importing.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/files/importing.md @@ -110,6 +110,52 @@ A notificação de redimensionamento aparece como uma mensagem toast: Isso garante que seus designs sempre se ajustem às capacidades da sua máquina, dando-lhe a flexibilidade de restaurar o tamanho original, se necessário. +### Mantendo os arquivos importados sincronizados + +Ao importar um arquivo, o Rayforge mantém um link para ele no disco. Se o arquivo mudar mais tarde — +por exemplo, depois de editá-lo e salvá-lo novamente no Inkscape ou no seu programa de CAD — o +Rayforge oferece recarregar o conteúdo importado, para que seu projeto não mantenha geometria +desatualizada. + +![O diálogo de arquivo importado alterado](/screenshots/source-changed-dialog.webp) + +Esse comportamento é controlado pela opção **Vigiar arquivos importados** em Configurações → Geral. +Quando ativada (o padrão), o Rayforge verifica os arquivos vinculados enquanto o projeto está aberto +— inclusive logo após abri-lo — e mostra um diálogo para cada arquivo que mudou no disco: + +- **Recarregar** substitui o conteúdo importado pela nova versão. A posição, o tamanho e as + configurações das peças de trabalho afetadas são mantidos, e toda a recarga é uma única etapa de + desfazer. +- **Ignorar** mantém o conteúdo atual. Você será perguntado novamente na próxima vez que o arquivo + mudar. +- Ative **Sempre recarregar este arquivo sem perguntar** para recarregar esse arquivo + automaticamente de agora em diante. Arquivos marcados assim são recarregados sem perguntar. + +Quando vários arquivos vinculados mudaram ao mesmo tempo, o diálogo os lista e permite escolher +quais recarregar. + + +:::note[A recarga ajusta o documento] +Recarregar traz o documento de volta em linha com o arquivo: elementos que foram adicionados a ele +aparecem no projeto, elementos que não estão mais nele são removidos, e abas de fixação que +acabarem a mais de 1 mm do novo contorno são descartadas. Elementos que você editou no Rayforge +ficam inalterados. Uma notificação resume o que a recarga alterou. +::: + + +### Recarga e revinculação manuais + +Você também pode recarregar ou revincular manualmente a qualquer momento via **Arquivo → Recarregar +do disco** e **Arquivo → Revincular arquivo de origem…**, ou pelo menu de contexto da seleção na +tela, da lista de camadas ou do navegador de ativos. **Recarregar do disco** relê os arquivos por +trás das peças de trabalho selecionadas. Quando o arquivo vinculado não pode ser encontrado, o +Rayforge sugere revinculá-lo em vez disso. + +**Revincular arquivo de origem…** aponta uma fonte importada para um arquivo diferente. Isso é útil +quando um arquivo foi movido ou renomeado, ou quando um projeto é aberto em outro computador e o +arquivo vinculado está ausente. As peças de trabalho mantêm sua posição e configurações e mostram o +conteúdo do novo arquivo. + --- ## Importação SVG diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/ui/settings.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/ui/settings.md index d55400876..e19eb20a7 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/ui/settings.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/ui/settings.md @@ -30,6 +30,11 @@ automaticamente** e acionar o recálculo manualmente pelo botão na barra de fer O Rayforge pode **Verificar atualizações** automaticamente na inicialização. Quando habilitado, você será notificado quando uma nova versão estiver disponível. +Com **Vigiar arquivos importados** ativado, o Rayforge monitora os arquivos por trás das peças de +trabalho importadas e oferece recarregá-los quando eles mudam no disco. Veja +[Mantendo os arquivos importados sincronizados](../files/importing.md#mantendo-os-arquivos-importados-sincronizados) +para mais detalhes. + Você também pode configurar o **Comportamento de inicialização** — iniciar com um espaço de trabalho vazio, reabrir o último projeto ou sempre abrir um arquivo de projeto específico. Note que arquivos especificados na linha de comando sempre substituirão essas configurações. diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/files/importing.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/files/importing.md index 6546f7f19..8e37f0cf9 100644 --- a/website/i18n/uk/docusaurus-plugin-content-docs/current/files/importing.md +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/files/importing.md @@ -107,6 +107,49 @@ rayforge file1.svg file2.dxf Це гарантує, що ваші дизайни завжди вміщуються в межах можливостей вашої машини, надаючи вам гнучкість для відновлення оригінального розміру за необхідності. +### Синхронізація імпортованих файлів + +Під час імпортування Rayforge зберігає посилання на файл на диску. Якщо файл пізніше змінюється — +наприклад, після редагування та повторного збереження в Inkscape або у вашій CAD-програмі — Rayforge +пропонує перезавантажити імпортований вміст, щоб ваш проект не зберігав застарілу геометрію. + +![Діалог «Імпортований файл змінено»](/screenshots/source-changed-dialog.webp) + +Це керується опцією **Стежити за імпортованими файлами** у Налаштування → Загальні. Коли вона +увімкнена (за замовчуванням), Rayforge перевіряє пов'язані файли, поки проект відкритий — зокрема +одразу після його відкриття — і показує діалог для кожного файлу, що змінився на диску: + +- **Перезавантажити** замінює імпортований вміст новою версією. Положення, розмір і налаштування + задіяних робочих деталей зберігаються, а все перезавантаження є одним кроком скасування. +- **Ігнорувати** зберігає поточний вміст. Вас запитають знову наступного разу, коли файл зміниться. +- Увімкніть **Завжди перезавантажувати цей файл без запитування**, щоб цей файл надалі + перезавантажувався автоматично. Позначені так файли перезавантажуються без запитування. + +Коли кілька пов'язаних файлів змінилися одночасно, діалог перелічує їх і дозволяє вибрати, які з них +перезавантажити. + + +:::note[Перезавантаження пристосовує документ] +Перезавантаження приводить документ у відповідність до файлу: елементи, додані до нього, з'являються +в проекті, елементи, яких у ньому більше немає, видаляються, а утримуючі лапки, що опинилися далі +ніж 1 мм від нового контуру, відкидаються. Елементи, які ви редагували в Rayforge, залишаються +без змін. Сповіщення підсумовує, що змінило перезавантаження. +::: + + +### Ручне перезавантаження та повторне прив'язування + +Ви також можете вручну перезавантажити або переприв'язати будь-коли через **Файл → Перезавантажити з +диска** та **Файл → Переприв'язати вихідний файл…**, або через контекстне меню виділення на полотні, +списку шарів чи браузері активів. **Перезавантажити з диска** повторно читає файли за виділеними +робочими деталями. Коли пов'язаний файл неможливо знайти, Rayforge натомість пропонує переприв'язати +його. + +**Переприв'язати вихідний файл…** вказує імпортованому джерелу на інший файл. Це корисно, коли файл +було переміщено або перейменовано, або коли проект відкривається на іншому комп'ютері, а пов'язаного +файлу там немає. Робочі деталі зберігають своє положення та налаштування і показують вміст нового +файлу. + --- ## Імпортування SVG diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/ui/settings.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/ui/settings.md index 3f84cdd6a..c652d3a07 100644 --- a/website/i18n/uk/docusaurus-plugin-content-docs/current/ui/settings.md +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/ui/settings.md @@ -30,6 +30,11 @@ Rayforge може **Перевіряти оновлення** автоматично при запуску. Якщо увімкнено, ви отримаєте сповіщення, коли буде доступна нова версія. +Коли **Стежити за імпортованими файлами** увімкнено, Rayforge стежить за файлами за імпортованими +робочими деталями та пропонує перезавантажити їх, коли вони змінюються на диску. Детальніше — у +розділі +[Синхронізація імпортованих файлів](../files/importing.md#синхронізація-імпортованих-файлів). + Ви також можете налаштувати **Поведінку при запуску** — починати з порожнього робочого простору, повторно відкривати останній проект або завжди відкривати певний файл проекту. Зверніть увагу, що файли, вказані в командному рядку, завжди перекриватимуть ці налаштування. diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/files/importing.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/files/importing.md index 1438188b2..36cc57e66 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/files/importing.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/files/importing.md @@ -100,6 +100,38 @@ rayforge file1.svg file2.dxf 这确保您的设计始终适应机器的能力范围,同时在需要时可以恢复原始尺寸。 +### 保持导入文件同步 + +导入文件时,Rayforge 会保留指向磁盘上该文件的链接。如果文件之后发生了变化——例如在 Inkscape 或您的 CAD 程序中编辑并重新保存后——Rayforge 会提议重新加载导入的内容,以免您的项目保留过时的几何图形。 + +![“导入文件已更改”对话框](/screenshots/source-changed-dialog.webp) + +此行为由 Settings → General 中的**监视导入文件**(Watch imported +files)选项控制。启用时(默认值),Rayforge 会在项目打开期间检查关联的文件——包括在打开项目之后立即检查——并为磁盘上每个已更改的文件显示一个对话框: + +- **重新加载**将导入的内容替换为新版本。受影响工件的位置、大小和设置保持不变,整个重新加载是单个撤销步骤。 +- **忽略**保留当前内容。下次该文件更改时会再次询问您。 +- 启用**始终重新加载此文件而不询问**后,该文件今后将自动重新加载。以这种方式标记的文件会直接重新加载而不再询问。 + +当多个关联文件同时更改时,对话框会列出它们,并允许您选择要重新加载的文件。 + + +:::note[重新加载会调整文档] +重新加载会使文档与文件重新保持一致:文件中新增的元素会出现在项目中,已从文件中移除的元素会被 +删除,而距新轮廓超过 1 mm 的固定标签会被丢弃。您在 Rayforge 中编辑过的元素保持不变。通知会 +概括重新加载所做的更改。 +::: + + +### 手动重新加载和重新链接 + +您也可以随时通过 **File → Reload from Disk**(从磁盘重新加载)和 **File → Relink Source +File…**(重新链接源文件)手动重新加载或重新链接,也可以通过画布所选内容、图层列表或资产浏览器的右键菜单操作。**Reload +from Disk** 会重新读取所选工件背后的文件。当找不到关联的文件时,Rayforge 会建议改为重新链接。 + +**Relink Source File…** +将导入的源指向另一个文件。这在文件被移动或重命名时,或者在另一台计算机上打开项目而关联文件缺失时非常有用。工件会保留其位置和设置,并显示新文件的内容。 + --- ## SVG 导入 diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/ui/settings.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/ui/settings.md index 8dc916d44..2050060e0 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/ui/settings.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/ui/settings.md @@ -24,6 +24,10 @@ Rayforge 可以在启动时**自动检查更新**。启用后,当有新版本可用时会通知您。 +启用**监视导入文件**(Watch imported +files)后,Rayforge 会监视导入工件背后的文件,并在它们在磁盘上发生更改时提议重新加载。详情请参阅 +[保持导入文件同步](../files/importing.md#保持导入文件同步)。 + 您还可以配置**启动行为** — 以空白工作区启动、重新打开上次的项目或始终打开特定的项目文件。请注意,命令行指定的文件将始终覆盖这些设置。 diff --git a/website/static/screenshots/source-changed-dialog.webp b/website/static/screenshots/source-changed-dialog.webp new file mode 100644 index 0000000000000000000000000000000000000000..d1cbbeed8725dea5741f0d28f04ff774f6b5a8e2 GIT binary patch literal 35814 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 a%YPZq&Ojihm;W+;02ZD5f$M?t@9n?oBXP+9 literal 0 HcmV?d00001 From 6f7cd13f6ba4de8c1b076459b5c36a9998a80f6e Mon Sep 17 00:00:00 2001 From: medoix Date: Mon, 12 Oct 2026 09:58:31 +1100 Subject: [PATCH 045/104] feat(machine): choose how Z homes in Homing and Startup Add a Z homing mode to the machine (Home Switch or Probe), stored next to home_on_start and single_axis_homing_enabled. Drivers declare the modes they support in z_homing_modes, and the first entry is the default for machines that have not chosen one. The Advanced settings page only shows the Z Homing row when the driver offers a choice. The Ruida driver supports both: Probe runs the controller's auto-focus routine (FOCUS_Z) and stays the default, so existing machines home Z as before. Home Switch sends HOME_Z for machines with a real Z home switch. Other drivers keep switch homing and show no new row. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011Z6AnQL3k4XUquSTjcSeFb --- rayforge/locale/de/LC_MESSAGES/rayforge.po | 18 +++++++ rayforge/locale/en/LC_MESSAGES/rayforge.po | 18 +++++++ rayforge/locale/es/LC_MESSAGES/rayforge.po | 18 +++++++ rayforge/locale/fr/LC_MESSAGES/rayforge.po | 18 +++++++ rayforge/locale/hi/LC_MESSAGES/rayforge.po | 18 +++++++ rayforge/locale/pt/LC_MESSAGES/rayforge.po | 18 +++++++ rayforge/locale/rayforge.pot | 18 +++++++ rayforge/locale/uk/LC_MESSAGES/rayforge.po | 18 +++++++ rayforge/locale/zh_CN/LC_MESSAGES/rayforge.po | 18 +++++++ rayforge/machine/driver/driver.py | 32 +++++++++++ .../machine/driver/ruidarpa/rpa_adapter.py | 7 ++- rayforge/machine/models/machine.py | 26 ++++++++- .../machine/advanced_preferences_page.py | 53 ++++++++++++++++++- tests/machine/driver/ruidarpa/test_adapter.py | 41 ++++++++++++++ tests/machine/models/test_machine_model.py | 50 +++++++++++++++++ .../machine/test_advanced_preferences_page.py | 51 ++++++++++++++++++ website/docs/machine/advanced.md | 9 ++++ .../current/machine/advanced.md | 9 ++++ .../current/machine/advanced.md | 9 ++++ .../current/machine/advanced.md | 9 ++++ .../current/machine/advanced.md | 8 +++ .../current/machine/advanced.md | 9 ++++ .../current/machine/advanced.md | 9 ++++ .../current/machine/advanced.md | 7 +++ 24 files changed, 488 insertions(+), 3 deletions(-) create mode 100644 tests/ui_gtk/machine/test_advanced_preferences_page.py diff --git a/rayforge/locale/de/LC_MESSAGES/rayforge.po b/rayforge/locale/de/LC_MESSAGES/rayforge.po index 48ddf3172..994cf0f3e 100644 --- a/rayforge/locale/de/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/de/LC_MESSAGES/rayforge.po @@ -10535,3 +10535,21 @@ msgstr "{filename} enthält keine HPGL-Zeichenbefehle." #, python-brace-format msgid "Pen {number}" msgstr "Stift {number}" + +#: rayforge/machine/driver/driver.py +msgid "Home Switch" +msgstr "Referenzschalter" + +#: rayforge/machine/driver/driver.py +msgid "Probe" +msgstr "Taster" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Z Homing" +msgstr "Z-Referenzfahrt" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "" +"How the Z axis finds its reference: a home switch, " +"or the auto-focus probe" +msgstr "Wie die Z-Achse ihre Referenz findet: über einen Referenzschalter oder den Autofokus-Taster" diff --git a/rayforge/locale/en/LC_MESSAGES/rayforge.po b/rayforge/locale/en/LC_MESSAGES/rayforge.po index 5a6d86ab4..911e21841 100644 --- a/rayforge/locale/en/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/en/LC_MESSAGES/rayforge.po @@ -9893,3 +9893,21 @@ msgstr "" #, python-brace-format msgid "Pen {number}" msgstr "" + +#: rayforge/machine/driver/driver.py +msgid "Home Switch" +msgstr "" + +#: rayforge/machine/driver/driver.py +msgid "Probe" +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Z Homing" +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "" +"How the Z axis finds its reference: a home switch, " +"or the auto-focus probe" +msgstr "" diff --git a/rayforge/locale/es/LC_MESSAGES/rayforge.po b/rayforge/locale/es/LC_MESSAGES/rayforge.po index 4c232f46c..176508446 100644 --- a/rayforge/locale/es/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/es/LC_MESSAGES/rayforge.po @@ -10509,3 +10509,21 @@ msgstr "{filename} no contiene comandos de dibujo HPGL." #, python-brace-format msgid "Pen {number}" msgstr "Pluma {number}" + +#: rayforge/machine/driver/driver.py +msgid "Home Switch" +msgstr "Interruptor de origen" + +#: rayforge/machine/driver/driver.py +msgid "Probe" +msgstr "Sonda" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Z Homing" +msgstr "Referenciado de Z" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "" +"How the Z axis finds its reference: a home switch, " +"or the auto-focus probe" +msgstr "Cómo encuentra el eje Z su referencia: un interruptor de origen o la sonda de autoenfoque" diff --git a/rayforge/locale/fr/LC_MESSAGES/rayforge.po b/rayforge/locale/fr/LC_MESSAGES/rayforge.po index 1fbc25f58..bb757befd 100644 --- a/rayforge/locale/fr/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/fr/LC_MESSAGES/rayforge.po @@ -10556,3 +10556,21 @@ msgstr "{filename} ne contient aucune commande de tracé HPGL." #, python-brace-format msgid "Pen {number}" msgstr "Plume {number}" + +#: rayforge/machine/driver/driver.py +msgid "Home Switch" +msgstr "Interrupteur d'origine" + +#: rayforge/machine/driver/driver.py +msgid "Probe" +msgstr "Palpeur" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Z Homing" +msgstr "Prise d'origine Z" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "" +"How the Z axis finds its reference: a home switch, " +"or the auto-focus probe" +msgstr "Comment l'axe Z trouve sa référence : un interrupteur d'origine ou le palpeur d'autofocus" diff --git a/rayforge/locale/hi/LC_MESSAGES/rayforge.po b/rayforge/locale/hi/LC_MESSAGES/rayforge.po index 30c5c8753..3a25b2c93 100644 --- a/rayforge/locale/hi/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/hi/LC_MESSAGES/rayforge.po @@ -10215,3 +10215,21 @@ msgstr "{filename} में कोई HPGL ड्रॉइंग कमां #, python-brace-format msgid "Pen {number}" msgstr "पेन {number}" + +#: rayforge/machine/driver/driver.py +msgid "Home Switch" +msgstr "होम स्विच" + +#: rayforge/machine/driver/driver.py +msgid "Probe" +msgstr "प्रोब" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Z Homing" +msgstr "Z होमिंग" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "" +"How the Z axis finds its reference: a home switch, " +"or the auto-focus probe" +msgstr "Z अक्ष अपना संदर्भ कैसे ढूँढता है: होम स्विच से, या ऑटो-फ़ोकस प्रोब से" diff --git a/rayforge/locale/pt/LC_MESSAGES/rayforge.po b/rayforge/locale/pt/LC_MESSAGES/rayforge.po index b1e251f3d..e90f34136 100644 --- a/rayforge/locale/pt/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/pt/LC_MESSAGES/rayforge.po @@ -10461,3 +10461,21 @@ msgstr "{filename} não contém comandos de desenho HPGL." #, python-brace-format msgid "Pen {number}" msgstr "Caneta {number}" + +#: rayforge/machine/driver/driver.py +msgid "Home Switch" +msgstr "Interruptor de Referência" + +#: rayforge/machine/driver/driver.py +msgid "Probe" +msgstr "Sonda" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Z Homing" +msgstr "Referenciação de Z" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "" +"How the Z axis finds its reference: a home switch, " +"or the auto-focus probe" +msgstr "Como o eixo Z encontra sua referência: um interruptor de referência ou a sonda de foco automático" diff --git a/rayforge/locale/rayforge.pot b/rayforge/locale/rayforge.pot index d19d7adb8..086802990 100644 --- a/rayforge/locale/rayforge.pot +++ b/rayforge/locale/rayforge.pot @@ -9894,3 +9894,21 @@ msgstr "" #, python-brace-format msgid "Pen {number}" msgstr "" + +#: rayforge/machine/driver/driver.py +msgid "Home Switch" +msgstr "" + +#: rayforge/machine/driver/driver.py +msgid "Probe" +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Z Homing" +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "" +"How the Z axis finds its reference: a home switch, " +"or the auto-focus probe" +msgstr "" diff --git a/rayforge/locale/uk/LC_MESSAGES/rayforge.po b/rayforge/locale/uk/LC_MESSAGES/rayforge.po index 2b275f418..4accb7547 100644 --- a/rayforge/locale/uk/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/uk/LC_MESSAGES/rayforge.po @@ -10421,3 +10421,21 @@ msgstr "{filename} не містить команд малювання HPGL." #, python-brace-format msgid "Pen {number}" msgstr "Перо {number}" + +#: rayforge/machine/driver/driver.py +msgid "Home Switch" +msgstr "Кінцевий вимикач" + +#: rayforge/machine/driver/driver.py +msgid "Probe" +msgstr "Зонд" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Z Homing" +msgstr "Паркування Z" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "" +"How the Z axis finds its reference: a home switch, " +"or the auto-focus probe" +msgstr "Як вісь Z знаходить свою точку відліку: за кінцевим вимикачем або зондом автофокусу" diff --git a/rayforge/locale/zh_CN/LC_MESSAGES/rayforge.po b/rayforge/locale/zh_CN/LC_MESSAGES/rayforge.po index 1e133c37b..61827cbf4 100644 --- a/rayforge/locale/zh_CN/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/zh_CN/LC_MESSAGES/rayforge.po @@ -10022,3 +10022,21 @@ msgstr "{filename} 不包含 HPGL 绘图命令。" #, python-brace-format msgid "Pen {number}" msgstr "笔 {number}" + +#: rayforge/machine/driver/driver.py +msgid "Home Switch" +msgstr "原点开关" + +#: rayforge/machine/driver/driver.py +msgid "Probe" +msgstr "探针" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Z Homing" +msgstr "Z轴回原点" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "" +"How the Z axis finds its reference: a home switch, " +"or the auto-focus probe" +msgstr "Z轴如何找到参考位置:通过原点开关,或通过自动对焦探针" diff --git a/rayforge/machine/driver/driver.py b/rayforge/machine/driver/driver.py index 18d182a64..ef797dbc9 100644 --- a/rayforge/machine/driver/driver.py +++ b/rayforge/machine/driver/driver.py @@ -86,6 +86,19 @@ class DriverMaturity(Enum): } +class ZHomingMode(Enum): + """How the Z axis finds its reference position when homed.""" + + SWITCH = "switch" + PROBE = "probe" + + +Z_HOMING_MODE_LABELS = { + ZHomingMode.SWITCH: _("Home Switch"), + ZHomingMode.PROBE: _("Probe"), +} + + class DeviceStatus(Enum): UNKNOWN = auto() IDLE = auto() @@ -289,6 +302,25 @@ class Driver(ABC): # rayforge.machine.discovery). Serial-only drivers declare only # the serial recognizer; network drivers only the mDNS one. DISCOVERY: ClassVar[DiscoverySpec | None] = None + # The Z homing modes the driver can perform. The first entry is + # the default for machines that have not chosen a mode. + z_homing_modes: ClassVar[tuple[ZHomingMode, ...]] = (ZHomingMode.SWITCH,) + + @classmethod + def resolve_z_homing_mode(cls, mode: ZHomingMode | None) -> ZHomingMode: + """ + Returns the Z homing mode to use for a machine's chosen mode. + + Falls back to the driver's default when no mode was chosen or + the driver does not support the chosen one. + """ + if mode is not None and mode in cls.z_homing_modes: + return mode + return cls.z_homing_modes[0] + + def get_z_homing_mode(self) -> ZHomingMode: + """Returns the Z homing mode configured for this machine.""" + return self.resolve_z_homing_mode(self._machine.z_homing_mode) @classmethod def supports_travel_speed(cls, dialect: "GcodeDialect | None") -> bool: diff --git a/rayforge/machine/driver/ruidarpa/rpa_adapter.py b/rayforge/machine/driver/ruidarpa/rpa_adapter.py index f492cecbf..7c81af90c 100644 --- a/rayforge/machine/driver/ruidarpa/rpa_adapter.py +++ b/rayforge/machine/driver/ruidarpa/rpa_adapter.py @@ -49,6 +49,7 @@ FrameCorner, Pos, PWMParams, + ZHomingMode, ) from rayforge.machine.driver.ruidarpa.rpa_direct_driver import RpaDirectDriver from rayforge.machine.driver.ruidarpa.rpa_encoder import ( @@ -217,6 +218,7 @@ class RuidaRPAAdapter(Driver): supports_probing = False native_overscan = True supports_multi_depth_raster = False + z_homing_modes = (ZHomingMode.PROBE, ZHomingMode.SWITCH) # --- Reconnect constants --- CONNECTION_POLL_INTERVAL = 0.5 @@ -1258,7 +1260,10 @@ async def home(self, axes: Axis | None = None) -> None: elif axes & Axis.Y: await loop.run_in_executor(None, self._backend.jog_y_to, 0.0) if axes is not None and (axes & Axis.Z): - await loop.run_in_executor(None, self._backend.focus_z) + if self.get_z_homing_mode() is ZHomingMode.SWITCH: + await loop.run_in_executor(None, self._backend.home_z) + else: + await loop.run_in_executor(None, self._backend.focus_z) async def move_to( self, diff --git a/rayforge/machine/models/machine.py b/rayforge/machine/models/machine.py index 848206833..02090a4e1 100644 --- a/rayforge/machine/models/machine.py +++ b/rayforge/machine/models/machine.py @@ -25,7 +25,13 @@ from ...shared.units.system import UnitSystem from ..assembly import Assembly from ..driver import get_driver_cls -from ..driver.driver import DeviceState, Pos, PWMParams, pwm_varset +from ..driver.driver import ( + DeviceState, + Pos, + PWMParams, + ZHomingMode, + pwm_varset, +) from ..kinematics import HeadSpec, Kinematics, build_assembly from ..models.axis import AxisConfig, AxisDirection, AxisSet, AxisType from ..transport import TransportStatus @@ -165,6 +171,8 @@ def __init__(self, context: RayforgeContext): # keeps the app usable before the user sets up a real machine. self.placeholder: bool = False self.single_axis_homing_enabled: bool = True + # None leaves the choice to the driver's default Z homing mode. + self.z_homing_mode: ZHomingMode | None = None self.dialect_uid: str | None = "grbl" self.dialect_migrated: bool = False self._hydrated_dialect: GcodeDialect | None = None @@ -760,6 +768,12 @@ def set_single_axis_homing_enabled(self, enabled: bool = True): self.single_axis_homing_enabled = enabled self.changed.send(self) + def set_z_homing_mode(self, mode: ZHomingMode | None): + if self.z_homing_mode == mode: + return + self.z_homing_mode = mode + self.changed.send(self) + def set_max_travel_speed(self, speed: int): if self.max_travel_speed == speed: return @@ -1747,6 +1761,9 @@ def to_dict(self, include_frozen_dialect: bool = True) -> dict[str, Any]: "placeholder": self.placeholder, "home_on_start": self.home_on_start, "single_axis_homing_enabled": self.single_axis_homing_enabled, + "z_homing_mode": ( + self.z_homing_mode.value if self.z_homing_mode else None + ), "dialect_uid": self.dialect_uid, "active_wcs": self.active_wcs, "coordinate_systems": [ @@ -1932,6 +1949,13 @@ def from_dict( "single_axis_homing_enabled", ma.single_axis_homing_enabled, ) + z_homing_mode = ma_data.pop("z_homing_mode", None) + try: + ma.z_homing_mode = ( + ZHomingMode(z_homing_mode) if z_homing_mode else None + ) + except ValueError: + logger.warning(f"Unknown Z homing mode '{z_homing_mode}'") dialect_uid = ma_data.pop("dialect_uid", None) legacy_dialect = ma_data.pop("dialect", None) diff --git a/rayforge/ui_gtk/machine/advanced_preferences_page.py b/rayforge/ui_gtk/machine/advanced_preferences_page.py index 4263ca6ef..845e239c8 100644 --- a/rayforge/ui_gtk/machine/advanced_preferences_page.py +++ b/rayforge/ui_gtk/machine/advanced_preferences_page.py @@ -1,8 +1,10 @@ import logging from gettext import gettext as _ -from gi.repository import Adw +from gi.repository import Adw, Gtk +from ...machine.driver import get_driver_cls +from ...machine.driver.driver import Z_HOMING_MODE_LABELS from ..shared.pref_rows.length_spin_row import LengthSpinRow from ..shared.preferences_page import TrackedPreferencesPage @@ -99,6 +101,21 @@ def __init__(self, machine, **kwargs): ) homing_group.add(single_axis_homing_row) + self.z_homing_row = Adw.ComboRow( + title=_("Z Homing"), + subtitle=_( + "How the Z axis finds its reference: a home switch, " + "or the auto-focus probe" + ), + ) + self._z_homing_modes = () + self._z_homing_driver_name = None + self._z_homing_handler = self.z_homing_row.connect( + "notify::selected", self.on_z_homing_mode_changed + ) + self._update_z_homing_row() + homing_group.add(self.z_homing_row) + clear_alarm_row = Adw.SwitchRow() clear_alarm_row.set_title(_("Clear Alarm On Connect")) clear_alarm_row.set_subtitle( @@ -113,6 +130,9 @@ def __init__(self, machine, **kwargs): ) homing_group.add(clear_alarm_row) + self.machine.changed.connect(self._on_machine_changed) + self.connect("destroy", self._on_destroy) + def on_arcs_changed(self, switch_row, _param): """Update the machine's arcs support when the value changes.""" self.machine.set_supports_arcs(switch_row.get_active()) @@ -133,3 +153,34 @@ def on_single_axis_homing_changed(self, row, _): def on_clear_alarm_on_connect_changed(self, row, _): self.machine.set_clear_alarm_on_connect(row.get_active()) + + def on_z_homing_mode_changed(self, row, _): + index = row.get_selected() + if 0 <= index < len(self._z_homing_modes): + self.machine.set_z_homing_mode(self._z_homing_modes[index]) + + def _on_machine_changed(self, machine): + if machine.driver_name != self._z_homing_driver_name: + self._update_z_homing_row() + + def _update_z_homing_row(self): + """ + Lists the driver's Z homing modes, and only shows the row when + there is a choice to make. + """ + self._z_homing_driver_name = self.machine.driver_name + driver_cls = get_driver_cls(self.machine.driver_name or "") + self._z_homing_modes = driver_cls.z_homing_modes + store = Gtk.StringList() + for mode in self._z_homing_modes: + store.append(Z_HOMING_MODE_LABELS[mode]) + mode = driver_cls.resolve_z_homing_mode(self.machine.z_homing_mode) + self.z_homing_row.handler_block(self._z_homing_handler) + self.z_homing_row.set_model(store) + self.z_homing_row.set_selected(self._z_homing_modes.index(mode)) + self.z_homing_row.handler_unblock(self._z_homing_handler) + self.z_homing_row.set_visible(len(self._z_homing_modes) > 1) + + def _on_destroy(self, *args): + """Disconnects signals to prevent memory leaks.""" + self.machine.changed.disconnect(self._on_machine_changed) diff --git a/tests/machine/driver/ruidarpa/test_adapter.py b/tests/machine/driver/ruidarpa/test_adapter.py index 94bf0cc4d..0977c4067 100644 --- a/tests/machine/driver/ruidarpa/test_adapter.py +++ b/tests/machine/driver/ruidarpa/test_adapter.py @@ -50,6 +50,7 @@ Driver, DriverPrecheckError, DriverSetupError, + ZHomingMode, ) from rayforge.machine.driver.ruidarpa import rpa_adapter from rayforge.machine.driver.ruidarpa.rpa_adapter import ( @@ -974,6 +975,46 @@ async def test_home_x_and_z(self, adapter_pair): backend.home.assert_not_called() +class TestZHomingMode: + """home(Axis.Z) follows the machine's Z homing mode.""" + + def test_probe_is_the_default(self): + """Machines without a chosen mode keep homing Z with the probe.""" + assert RuidaRPAAdapter.resolve_z_homing_mode(None) is ( + ZHomingMode.PROBE + ) + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "adapter_pair", + [DIRECT_MODE, RPC_MODE], + ids=["direct", "rpc"], + indirect=True, + ) + async def test_switch_mode_uses_home_z(self, adapter_pair): + """The home switch mode sends HOME_Z instead of auto-focus.""" + adapter, backend = adapter_pair + adapter._machine.set_z_homing_mode(ZHomingMode.SWITCH) + await adapter.home(Axis.Z) + backend.home_z.assert_called_once() + backend.focus_z.assert_not_called() + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "adapter_pair", + [DIRECT_MODE, RPC_MODE], + ids=["direct", "rpc"], + indirect=True, + ) + async def test_probe_mode_uses_focus_z(self, adapter_pair): + """The probe mode runs the controller's auto-focus routine.""" + adapter, backend = adapter_pair + adapter._machine.set_z_homing_mode(ZHomingMode.PROBE) + await adapter.home(Axis.Z) + backend.focus_z.assert_called_once() + backend.home_z.assert_not_called() + + class TestLiveBridgeDirect: """Direct live bridge delegates jog/home to the backend wrapper.""" diff --git a/tests/machine/models/test_machine_model.py b/tests/machine/models/test_machine_model.py index 5ed3ac217..5e70a8303 100644 --- a/tests/machine/models/test_machine_model.py +++ b/tests/machine/models/test_machine_model.py @@ -20,6 +20,7 @@ from rayforge import context as context_module from rayforge.context import get_context from rayforge.core.doc import Doc +from rayforge.machine.driver.driver import Driver, ZHomingMode from rayforge.machine.models.dialect_manager import DialectManager from rayforge.machine.models.machine import Machine, Origin from rayforge.machine.models.rotary_module import RotaryModule @@ -561,6 +562,55 @@ def test_serialization_round_trip(self, lite_context): assert restored.supports_curves is True +class TestZHomingMode: + def test_default_is_driver_default(self, lite_context): + machine = Machine(lite_context) + assert machine.z_homing_mode is None + + def test_no_signal_on_same_value(self, lite_context): + machine = Machine(lite_context) + signals = [] + machine.changed.connect(lambda m: signals.append(m)) + machine.set_z_homing_mode(None) + assert len(signals) == 0 + + def test_signal_on_change(self, lite_context): + machine = Machine(lite_context) + signals = [] + + def handler(sender): + signals.append(sender) + + machine.changed.connect(handler) + machine.set_z_homing_mode(ZHomingMode.PROBE) + assert len(signals) == 1 + + def test_serialization_round_trip(self, lite_context): + machine = Machine(lite_context) + machine.set_z_homing_mode(ZHomingMode.PROBE) + data = machine.to_dict() + restored = Machine.from_dict(data, context=lite_context) + assert restored.z_homing_mode is ZHomingMode.PROBE + + def test_unknown_mode_falls_back_to_driver_default(self, lite_context): + data = Machine(lite_context).to_dict() + data["machine"]["z_homing_mode"] = "laser-eye" + restored = Machine.from_dict(data, context=lite_context) + assert restored.z_homing_mode is None + + +class TestDriverZHomingModes: + def test_drivers_default_to_switch_only(self): + assert Driver.z_homing_modes == (ZHomingMode.SWITCH,) + + def test_unset_mode_resolves_to_first_supported(self): + assert Driver.resolve_z_homing_mode(None) is ZHomingMode.SWITCH + + def test_unsupported_mode_resolves_to_default(self): + resolved = Driver.resolve_z_homing_mode(ZHomingMode.PROBE) + assert resolved is ZHomingMode.SWITCH + + class TestSetSupportsArcs: """Tests for the set_supports_arcs setter.""" diff --git a/tests/ui_gtk/machine/test_advanced_preferences_page.py b/tests/ui_gtk/machine/test_advanced_preferences_page.py new file mode 100644 index 000000000..716523aac --- /dev/null +++ b/tests/ui_gtk/machine/test_advanced_preferences_page.py @@ -0,0 +1,51 @@ +"""Tests for the Z homing row in the Advanced machine settings.""" + +import pytest + +from rayforge.machine.driver.driver import ZHomingMode +from rayforge.machine.models.machine import Machine +from rayforge.ui_gtk.machine.advanced_preferences_page import ( + AdvancedPreferencesPage, +) + +pytestmark = pytest.mark.ui + + +def test_z_homing_hidden_without_a_choice(ui_context_initializer): + machine = Machine(ui_context_initializer) + machine.driver_name = "GrblSerialDriver" + page = AdvancedPreferencesPage(machine) + + assert not page.z_homing_row.get_visible() + + +def test_z_homing_shows_driver_default(ui_context_initializer): + machine = Machine(ui_context_initializer) + machine.driver_name = "RuidaRPAAdapter" + page = AdvancedPreferencesPage(machine) + + assert page.z_homing_row.get_visible() + assert page.z_homing_row.get_selected() == 0 + assert machine.z_homing_mode is None + + +def test_z_homing_selection_updates_machine(ui_context_initializer): + machine = Machine(ui_context_initializer) + machine.driver_name = "RuidaRPAAdapter" + page = AdvancedPreferencesPage(machine) + + page.z_homing_row.set_selected(1) + + assert machine.z_homing_mode is ZHomingMode.SWITCH + + +def test_z_homing_follows_driver_change(ui_context_initializer): + machine = Machine(ui_context_initializer) + machine.driver_name = "GrblSerialDriver" + page = AdvancedPreferencesPage(machine) + + machine.driver_name = "RuidaRPAAdapter" + machine.changed.send(machine) + + assert page.z_homing_row.get_visible() + assert machine.z_homing_mode is None diff --git a/website/docs/machine/advanced.md b/website/docs/machine/advanced.md index 5ee75d5d9..5262cf891 100644 --- a/website/docs/machine/advanced.md +++ b/website/docs/machine/advanced.md @@ -34,6 +34,15 @@ When enabled, Rayforge automatically clears any alarm state when connecting. When enabled, you can home individual axes independently (X, Y, or Z) rather than requiring all axes to home together. This is useful for machines where one axis may already be positioned correctly. +### Z Homing + +Chooses how the Z axis finds its reference position when you home it. This setting only appears when +the machine's driver supports more than one method. + +- **Home Switch**: Z moves to its home switch, like the X and Y axes +- **Probe**: Z uses the controller's auto-focus probe to find the work surface, then moves to the + configured focus distance + ## Arc and Curve Settings Settings for controlling how curved paths are converted to G-code movements. diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/machine/advanced.md b/website/i18n/de/docusaurus-plugin-content-docs/current/machine/advanced.md index 5691550c6..a71a94cf4 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/machine/advanced.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/machine/advanced.md @@ -30,6 +30,15 @@ Wenn aktiviert, kannst du einzelne Achsen unabhängig referenzieren (X, Y oder Z Achsen gleichzeitig zu referenzieren. Dies ist nützlich für Maschinen, bei denen eine Achse bereits korrekt positioniert ist. +### Z-Referenzfahrt + +Legt fest, wie die Z-Achse beim Referenzieren ihre Referenzposition findet. Diese Einstellung +erscheint nur, wenn der Treiber der Maschine mehr als eine Methode unterstützt. + +- **Referenzschalter**: Z fährt wie die X- und Y-Achse zu ihrem Referenzschalter +- **Taster**: Z findet die Werkstückoberfläche mit dem Autofokus-Taster der Steuerung und fährt dann + auf den eingestellten Fokusabstand + ## Bogen- und Kurveneinstellungen Einstellungen zur Steuerung, wie gekrümmte Pfade in G-Code-Bewegungen umgewandelt werden. diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/machine/advanced.md b/website/i18n/es/docusaurus-plugin-content-docs/current/machine/advanced.md index 809755032..77a43c254 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/machine/advanced.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/machine/advanced.md @@ -30,6 +30,15 @@ Cuando está habilitado, puedes hacer homing de ejes individuales independientem lugar de requerir que todos los ejes hagan homing juntos. Esto es útil para máquinas donde un eje ya puede estar posicionado correctamente. +### Referenciado de Z + +Elige cómo encuentra el eje Z su posición de referencia al hacer homing. Este ajuste solo aparece +cuando el controlador de la máquina admite más de un método. + +- **Interruptor de origen**: Z se mueve a su interruptor de origen, igual que los ejes X e Y +- **Sonda**: Z usa la sonda de autoenfoque del controlador para encontrar la superficie de trabajo y + luego se mueve a la distancia de enfoque configurada + ## Ajustes de Arcos y Curvas Ajustes para controlar cómo las rutas curvas se convierten en movimientos de G-code. diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/machine/advanced.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/machine/advanced.md index 9980eaeb0..dd3f35f36 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/machine/advanced.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/machine/advanced.md @@ -31,6 +31,15 @@ Lorsqu'activé, vous pouvez effectuer le homing d'axes individuels indépendamme de requiring tous les axes ensemble. Ceci est utile pour les machines où un axe peut déjà être correctement positionné. +### Prise d'origine Z + +Choisit comment l'axe Z trouve sa position de référence lors du homing. Ce paramètre n'apparaît que +si le pilote de la machine prend en charge plusieurs méthodes. + +- **Interrupteur d'origine** : Z rejoint son interrupteur d'origine, comme les axes X et Y +- **Palpeur** : Z utilise le palpeur d'autofocus du contrôleur pour trouver la surface de travail, + puis se place à la distance de mise au point configurée + ## Paramètres d'Arcs et de Courbes Paramètres pour contrôler comment les chemins courbes sont convertis en mouvements G-code. diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/machine/advanced.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/machine/advanced.md index 5a47b226d..8f0552f8e 100644 --- a/website/i18n/hi/docusaurus-plugin-content-docs/current/machine/advanced.md +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/machine/advanced.md @@ -33,6 +33,14 @@ description: सक्षम होने पर, आप व्यक्तिगत अक्षों को स्वतंत्र रूप से होम कर सकते हैं (X, Y, या Z) बजाय इसके कि सभी अक्ष एक साथ होम हों. यह उन मशीनों के लिए उपयोगी है जहाँ एक अक्ष पहले से सही स्थित में हो सकता है. +### Z होमिंग + +चुनता है कि होम करते समय Z अक्ष अपनी संदर्भ स्थिति कैसे ढूँढता है. यह सेटिंग तभी दिखती है जब मशीन का ड्राइवर एक से +अधिक तरीकों का समर्थन करता है. + +- **होम स्विच**: Z, X और Y अक्षों की तरह, अपने होम स्विच तक जाता है +- **प्रोब**: Z कंट्रोलर के ऑटो-फ़ोकस प्रोब से कार्य सतह ढूँढता है, फिर सेट की गई फ़ोकस दूरी पर जाता है + ## चाप और वक्र सेटिंग्स नियंत्रित करने की सेटिंग्स कि वक्र पाथ G-code गतियों में कैसे बदले जाते हैं. diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/machine/advanced.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/machine/advanced.md index 2b9407eef..595487b62 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/machine/advanced.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/machine/advanced.md @@ -29,6 +29,15 @@ Quando habilitado, você pode fazer homing de eixos individuais independentement de requerer todos os eixos juntos. Isso é útil para máquinas onde um eixo já pode estar posicionado corretamente. +### Referenciação de Z + +Escolhe como o eixo Z encontra sua posição de referência ao fazer homing. Esta configuração só +aparece quando o driver da máquina suporta mais de um método. + +- **Interruptor de Referência**: Z se move até seu interruptor de referência, como os eixos X e Y +- **Sonda**: Z usa a sonda de foco automático do controlador para encontrar a superfície de trabalho + e depois se move para a distância de foco configurada + ## Configurações de Arcos e Curvas Configurações para controlar como caminhos curvos são convertidos em movimentos de G-code. diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/machine/advanced.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/machine/advanced.md index 7c00414b5..41d34b9b9 100644 --- a/website/i18n/uk/docusaurus-plugin-content-docs/current/machine/advanced.md +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/machine/advanced.md @@ -28,6 +28,15 @@ Коли увімкнено, ви можете виконувати homing окремих осей незалежно (X, Y або Z) замість вимоги всіх осей разом. Це корисно для машин, де одна вісь вже може бути правильно позиціонована. +### Паркування Z + +Визначає, як вісь Z знаходить свою точку відліку під час паркування. Цей параметр з'являється лише +тоді, коли драйвер машини підтримує більше одного способу. + +- **Кінцевий вимикач**: Z рухається до свого кінцевого вимикача, як осі X та Y +- **Зонд**: Z знаходить робочу поверхню зондом автофокусу контролера, а потім переходить на + налаштовану фокусну відстань + ## Налаштування дуг та кривих Налаштування для контролю того, як криві шляхи перетворюються на рухи G-code. diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/machine/advanced.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/machine/advanced.md index 18ff9f876..f9cf72f2d 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/machine/advanced.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/machine/advanced.md @@ -26,6 +26,13 @@ 启用后,您可以独立归零单个轴(X、Y 或 Z),而不需要所有轴一起归零。这对于某个轴已经正确定位的机器很有用。 +### Z轴回原点 + +选择 Z 轴在回原点时如何找到参考位置。仅当机器的驱动程序支持多种方式时才会显示此设置。 + +- **原点开关**:Z 轴像 X 轴和 Y 轴一样移动到其原点开关 +- **探针**:Z 轴使用控制器的自动对焦探针找到工作表面,然后移动到设定的焦距 + ## 弧线和曲线设置 控制曲线路径如何转换为 G-code 移动的设置。 From af9e7574a93534d05f3e2844d36ee3b75b6b67b9 Mon Sep 17 00:00:00 2001 From: Samuel Abels Date: Mon, 12 Oct 2026 01:07:00 +0200 Subject: [PATCH 046/104] feat(screenshots): capture the bidirectional scan offset table --- scripts/screenshot/cli.py | 3 + .../screenshot/machine_settings_advanced.py | 103 +++++++++++++++++- ...-settings-advanced-bidir-offset-table.webp | Bin 0 -> 15182 bytes .../machine-settings-advanced.webp | Bin 33434 -> 41034 bytes 4 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 website/static/screenshots/machine-settings-advanced-bidir-offset-table.webp diff --git a/scripts/screenshot/cli.py b/scripts/screenshot/cli.py index 880a53fd8..159c36500 100755 --- a/scripts/screenshot/cli.py +++ b/scripts/screenshot/cli.py @@ -54,6 +54,9 @@ "machine-settings:general": "machine_settings_general", "machine-settings:hardware": "machine_settings_hardware", "machine-settings:advanced": "machine_settings_advanced", + "machine-settings:advanced:bidir-offset-table": ( + "machine_settings_advanced" + ), "machine-settings:gcode": "machine_settings_gcode", "machine-settings:hooks-macros": "machine_settings_hooks-macros", "machine-settings:hooks-macros:editor": "macro_editor", diff --git a/scripts/screenshot/machine_settings_advanced.py b/scripts/screenshot/machine_settings_advanced.py index 9a3663994..06ffb5bc2 100644 --- a/scripts/screenshot/machine_settings_advanced.py +++ b/scripts/screenshot/machine_settings_advanced.py @@ -1,26 +1,123 @@ -"""Screenshot: Machine settings - Advanced page.""" +""" +Screenshot: Machine settings - Advanced page and the bidirectional +scan offset table. + +Usage: + pixi run screenshot machine-settings:advanced + pixi run screenshot machine-settings:advanced:bidir-offset-table +""" import logging import time +from PIL import Image from utils import ( + DECOR_MARGIN_PX, + DECOR_OFFSET_Y_PX, + OUTPUT_DIR, + _add_window_decorations, + _save_webp_deterministic, + get_target, open_machine_settings, restore_config, + run_on_main_thread, take_screenshot, + target_to_filename, ) +from rayforge.context import get_context from rayforge.uiscript import app, win logger = logging.getLogger(__name__) PAGE = "advanced" +TABLE_ROWS = [(1000, 0.10), (3000, 0.25), (6000, 0.45), (10000, 0.80)] + + +def stage_offset_table() -> None: + """Stage a representative offset table on the active machine.""" + + def _stage(): + machine = get_context().config.machine + machine.set_bidir_offset_table(TABLE_ROWS) + + run_on_main_thread(_stage) + time.sleep(0.25) + + +def _expand_first_row(dialog): + """Show the speed and offset fields of the table's first row.""" + + def _expand(): + page = dialog.content_stack.get_child_by_name(PAGE) + group = page.bidir_offset_group + if group.entry_rows: + group.entry_rows[0].set_expanded(True) + + return _expand + + +def enlarge_dialog(dialog) -> None: + """Grow the dialog so the advanced page fits without scrolling.""" + + def _resize(): + dialog.set_default_size(800, 1240) + + run_on_main_thread(_resize) + time.sleep(0.5) + + +def capture_offset_table(dialog) -> None: + """Crop the bidirectional scan offset group from the dialog shot.""" + output_path = OUTPUT_DIR / target_to_filename(get_target(PAGE)) + if not output_path.exists(): + logger.error("Dialog screenshot is missing for cropping") + return + + def _group_box(): + page = dialog.content_stack.get_child_by_name(PAGE) + group = page.bidir_offset_group + pos = group.translate_coordinates(dialog, 0, 0) + return pos, group.get_width(), group.get_height() + + box = run_on_main_thread(_group_box) + if box is None: + logger.error("Could not locate the offset table group") + return + (x, y), w, h = box + pad = 14 + ox = DECOR_MARGIN_PX + oy = DECOR_MARGIN_PX + DECOR_OFFSET_Y_PX + img = Image.open(output_path) + cropped = _add_window_decorations( + img.crop( + ( + max(x - pad + ox, 0), + max(y - pad + oy, 0), + min(x + w + pad + ox, img.width), + min(y + h + pad + oy, img.height), + ) + ) + ) + _save_webp_deterministic(cropped, output_path) + @restore_config def main(): + target = get_target(f"machine-settings:{PAGE}") time.sleep(0.25) - open_machine_settings(win, PAGE) + stage_offset_table() + dialog = open_machine_settings(win, PAGE) time.sleep(0.25) - take_screenshot() + enlarge_dialog(dialog) + if target.endswith(":bidir-offset-table"): + run_on_main_thread(_expand_first_row(dialog)) + time.sleep(0.25) + take_screenshot() + time.sleep(0.25) + capture_offset_table(dialog) + else: + take_screenshot() time.sleep(0.25) app.quit_idle() diff --git a/website/static/screenshots/machine-settings-advanced-bidir-offset-table.webp b/website/static/screenshots/machine-settings-advanced-bidir-offset-table.webp new file mode 100644 index 0000000000000000000000000000000000000000..dc25627d8ab8d50e114e71c76e009f91b4e49bec GIT binary patch literal 15182 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 J{73Dx{Xbl$y3GIp literal 0 HcmV?d00001 diff --git a/website/static/screenshots/machine-settings-advanced.webp b/website/static/screenshots/machine-settings-advanced.webp index 2fbd852f8eeea13228da48e234eae80d89c404b7..d49d59480a31f2c626ceb5b31502397c9f0414ad 100644 GIT binary patch literal 41034 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 From d0adb07352b8eddadd115d0e2285a87891162433 Mon Sep 17 00:00:00 2001 From: Samuel Abels Date: Mon, 12 Oct 2026 01:07:44 +0200 Subject: [PATCH 047/104] docs: document the speed-dependent bidirectional scan offset table --- scripts/media/gen_bidir_offset_diagram.py | 136 ++++++++++++++++++ website/docs/features/operations/engrave.md | 12 +- website/docs/machine/advanced.md | 33 +++++ .../current/features/operations/engrave.md | 12 +- .../current/machine/advanced.md | 37 +++++ .../current/features/operations/engrave.md | 12 +- .../current/machine/advanced.md | 37 +++++ .../current/features/operations/engrave.md | 12 +- .../current/machine/advanced.md | 38 +++++ .../current/features/operations/engrave.md | 13 +- .../current/machine/advanced.md | 35 +++++ .../current/features/operations/engrave.md | 12 +- .../current/machine/advanced.md | 37 +++++ .../current/features/operations/engrave.md | 12 +- .../current/machine/advanced.md | 35 +++++ .../current/features/operations/engrave.md | 13 +- .../current/machine/advanced.md | 24 ++++ 17 files changed, 486 insertions(+), 24 deletions(-) create mode 100755 scripts/media/gen_bidir_offset_diagram.py diff --git a/scripts/media/gen_bidir_offset_diagram.py b/scripts/media/gen_bidir_offset_diagram.py new file mode 100755 index 000000000..90793a90d --- /dev/null +++ b/scripts/media/gen_bidir_offset_diagram.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""Generate the bidirectional scan offset diagrams for the website docs. + +Renders how right-to-left raster passes misalign by an amount that +grows with engrave speed, and how the machine's speed-dependent offset +table straightens the edges back out, into two images under +``website/static/images/``. The images carry no translatable words; +titles live in the markdown that embeds them. +""" + +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont + +PROJECT_ROOT = Path(__file__).parent.parent.parent +OUTPUT_DIR = PROJECT_ROOT / "website" / "static" / "images" + +BG = "white" +CARD_FILL = "#f7f8fa" +CARD_BORDER = "#e3e6eb" +LINE = "#2c313a" +GUIDE = "#c3c9d2" +MUTED = "#6b7280" + +SPEEDS = [1000, 4000, 8000] +UNCORRECTED_OFFSETS = [6, 14, 24] +MAX_OFFSET = max(UNCORRECTED_OFFSETS) + +CARD_W, CARD_H = 280, 170 +CARD_GAP = 30 +CARD_RADIUS = 12 +RECT_INSET = 16 +LINE_PITCH = 8 +LINE_THICKNESS = 3 + +FONT_DIRS = [ + Path("/usr/share/fonts/truetype/dejavu"), + Path("/usr/share/fonts/truetype/liberation"), + Path("/usr/share/fonts/truetype/freefont"), +] + + +def find_font(name: str, size: int) -> ImageFont.FreeTypeFont | None: + for directory in FONT_DIRS: + path = directory / name + if path.exists(): + try: + return ImageFont.truetype(str(path), size) + except OSError: + return None + return None + + +def draw_dashed_line( + draw: ImageDraw.ImageDraw, + x: int, + y0: int, + y1: int, + dash: int = 5, + gap: int = 4, +) -> None: + y = y0 + while y < y1: + draw.line([(x, y), (x, min(y + dash, y1))], fill=GUIDE, width=1) + y += dash + gap + + +def draw_engraved_rect( + draw: ImageDraw.ImageDraw, + x: int, + y: int, + w: int, + h: int, + offset: int, + corrected: bool, +) -> None: + draw_dashed_line(draw, x - 3, y - 6, y + h + 6) + draw_dashed_line(draw, x + w + 2, y - 6, y + h + 6) + for index, line_y in enumerate(range(y, y + h, LINE_PITCH)): + shift = 0 if corrected or index % 2 == 0 else offset + draw.rectangle( + [x + shift, line_y, x + shift + w, line_y + LINE_THICKNESS], + fill=LINE, + ) + + +def render(corrected: bool) -> Image.Image: + label_font = find_font("DejaVuSans.ttf", 18) + + columns = len(SPEEDS) + width = 24 + columns * CARD_W + (columns - 1) * CARD_GAP + 24 + height = 24 + CARD_H + 12 + 30 + img = Image.new("RGB", (width, height), BG) + draw = ImageDraw.Draw(img) + + for col in range(columns): + cx = 24 + col * (CARD_W + CARD_GAP) + draw.rounded_rectangle( + [cx, 24, cx + CARD_W, 24 + CARD_H], + radius=CARD_RADIUS, + fill=CARD_FILL, + outline=CARD_BORDER, + width=1, + ) + draw_engraved_rect( + draw, + cx + RECT_INSET, + 24 + RECT_INSET, + CARD_W - 2 * RECT_INSET - MAX_OFFSET, + CARD_H - 2 * RECT_INSET, + UNCORRECTED_OFFSETS[col], + corrected, + ) + draw.text( + (cx + CARD_W / 2, 24 + CARD_H + 12), + f"{SPEEDS[col]} mm/min", + fill=MUTED, + font=label_font, + anchor="ma", + ) + return img + + +def main() -> None: + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + for corrected, name in ( + (False, "bidir-scan-offset-uncorrected.webp"), + (True, "bidir-scan-offset-table-applied.webp"), + ): + path = OUTPUT_DIR / name + render(corrected).save(path, format="WEBP", lossless=True) + print(f"Saved {path}") + + +if __name__ == "__main__": + main() diff --git a/website/docs/features/operations/engrave.md b/website/docs/features/operations/engrave.md index 7849a1240..f026b9238 100644 --- a/website/docs/features/operations/engrave.md +++ b/website/docs/features/operations/engrave.md @@ -309,7 +309,8 @@ Use invert for lithophanes (light areas should be thin) or embossing (raised are :::tip[Resolution Match] For images, line interval should match or exceed image resolution. If your image is 10 pixels/mm -(254 DPI), use 0.1mm line interval or smaller.::: +(254 DPI), use 0.1mm line interval or smaller. +::: #### Scan Direction @@ -343,8 +344,9 @@ passes, which otherwise misaligns alternate scan rows (visible as banding, espec engraves). - Set in millimeters, positive or negative depending on which direction needs to shift -- Applies a constant shift regardless of speed; if the skew varies with speed, calibrate for your - typical engraving speed +- A non-zero value here always wins; when it is 0, the step falls back to the machine's + [Bidirectional Scan Offset by Speed](../../machine/advanced.md#bidirectional-scan-offset-by-speed) + table, which interpolates an offset for this step's speed - Defaults to 0 (no correction) **Calibrating the offset:** @@ -354,6 +356,10 @@ engraves). 2. Compare alternate rows to find the direction and amount of misalignment 3. Adjust the offset in small increments (0.01-0.05mm) and re-test until alternating rows line up +If the misalignment changes noticeably across your usual engraving speeds, a single offset cannot +fix every job. Leave the step offset at 0 and enter a speed-dependent offset table in +[Machine Settings - Advanced](../../machine/advanced.md#bidirectional-scan-offset-by-speed) instead. + ## Laser settings ![Laser settings](/screenshots/step-settings-engrave-laser.webp) diff --git a/website/docs/machine/advanced.md b/website/docs/machine/advanced.md index 5ee75d5d9..c621b9da4 100644 --- a/website/docs/machine/advanced.md +++ b/website/docs/machine/advanced.md @@ -61,6 +61,39 @@ commands. A larger value allows more deviation but generates fewer commands. Typical values range from 0.01mm for precision work to 0.1mm for faster processing. +## Bidirectional Scan Offset by Speed + +The Bidirectional Scan Offset by Speed table corrects the horizontal misalignment between +left-to-right and right-to-left raster passes. On many machines this skew is not constant: the +faster the laser head moves, the further the right-to-left passes drift out of alignment, which +shows up as banding that gets worse at higher engraving speeds. + +![Bidirectional Scan Offset by Speed](/screenshots/machine-settings-advanced-bidir-offset-table.webp) + +Each row of the table pairs an engraving speed with the offset, in millimeters, that right-to-left +passes need at that speed: + +- Use **Add Row** to add a row and the trash button on a row to remove it +- Positive values shift right-to-left passes to the right, negative values to the left +- Rows are kept sorted by speed; if two rows have the same speed, the last one wins + +When an engrave step runs, Rayforge looks up the step's speed in this table and interpolates the +offset linearly between the surrounding rows. Speeds below the first row or above the last row use +the nearest row's offset, and an empty table means every speed uses an offset of 0. + +**Without correction** — the misalignment grows with the engraving speed: + +![Raster passes misaligned at 1000, 4000 and 8000 mm/min](/images/bidir-scan-offset-uncorrected.webp) + +**With the offset table applied:** + +![Raster passes aligned at every speed](/images/bidir-scan-offset-table-applied.webp) + +The table only applies to engrave steps whose own **Bidirectional Scan Offset** is set to 0; a +non-zero per-step offset always takes precedence. See +[Bidirectional Scan Offset](../features/operations/engrave.md#bidirectional-scan-offset) for how the +misalignment looks and how to calibrate it. + ## See Also - [Hardware Settings](hardware) - Axis origin and flip settings diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/engrave.md b/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/engrave.md index c245fac9c..ad63b2810 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/engrave.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/engrave.md @@ -340,15 +340,16 @@ auf einigen Maschinen sichtbare Bänderung verursachen. Falls du das siehst, kal beschriebenen **Bidirektionalen Scan-Offset**, um es direkt zu korrigieren, anstatt den Geschwindigkeitsvorteil zu verlieren. -#### Bidirektionaler Scan-Offset +#### Bidirektionaler Scan-Offset {#bidirectional-scan-offset} Korrigiert eine feste mechanische oder schaltzeitbedingte Versetzung zwischen Links-nach-rechts- und Rechts-nach-links-Rasterdurchgängen, die ansonsten abwechselnde Scan-Zeilen verschiebt (als Bänderung sichtbar, besonders bei Fotogravuren). - In Millimetern angeben, positiv oder negativ, je nachdem welche Richtung verschoben werden muss -- Wendet eine konstante Verschiebung unabhängig von der Geschwindigkeit an; wenn die Versetzung mit - der Geschwindigkeit variiert, kalibriere für deine typische Gravurgeschwindigkeit +- Ein Wert ungleich 0 hier gewinnt immer; ist er 0, greift für den Schritt die Tabelle + [Bidirektionaler Scan-Offset nach Geschwindigkeit](../../machine/advanced.md#bidirectional-scan-offset-by-speed) + der Maschine, die für die Geschwindigkeit dieses Schritts einen Offset interpoliert - Standard ist 0 (keine Korrektur) **Offset kalibrieren:** @@ -359,6 +360,11 @@ Bänderung sichtbar, besonders bei Fotogravuren). 3. Passe den Offset in kleinen Schritten an (0.01-0.05mm) und teste erneut, bis abwechselnde Zeilen ausgerichtet sind +Wenn die Fehlausrichtung über deinen üblichen Gravurgeschwindigkeiten deutlich variiert, kann ein +einzelner Offset nicht jeden Job korrigieren. Lasse den Offset des Schritts auf 0 und hinterlege +stattdessen eine geschwindigkeitsabhängige Offset-Tabelle in den +[Maschineneinstellungen - Erweitert](../../machine/advanced.md#bidirectional-scan-offset-by-speed). + ## Laser-Einstellungen ![Laser-Einstellungen](/screenshots/step-settings-engrave-laser.webp) diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/machine/advanced.md b/website/i18n/de/docusaurus-plugin-content-docs/current/machine/advanced.md index 5691550c6..9bccc4520 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/machine/advanced.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/machine/advanced.md @@ -59,6 +59,43 @@ Befehle erfordern. Ein größerer Wert erlaubt mehr Abweichung, generiert aber w Typische Werte reichen von 0,01mm für Präzisionsarbeit bis 0,1mm für schnellere Verarbeitung. +## Bidirektionaler Scan-Offset nach Geschwindigkeit {#bidirectional-scan-offset-by-speed} + +Die Tabelle für den bidirektionalen Scan-Offset nach Geschwindigkeit korrigiert die horizontale +Fehlausrichtung zwischen Links-nach-rechts- und Rechts-nach-links-Rasterdurchgängen. Auf vielen +Maschinen ist diese Versetzung nicht konstant: Je schneller sich der Laserkopf bewegt, desto weiter +driften die Rechts-nach-links-Durchgänge aus der Ausrichtung, was als Bänderung sichtbar wird, die +bei höheren Gravurgeschwindigkeiten schlimmer wird. + +![Bidirektionaler Scan-Offset nach Geschwindigkeit](/screenshots/machine-settings-advanced-bidir-offset-table.webp) + +Jede Zeile der Tabelle verknüpft eine Gravurgeschwindigkeit mit dem Offset in Millimetern, den +Rechts-nach-links-Durchgänge bei dieser Geschwindigkeit benötigen: + +- Mit **Zeile hinzufügen** fügst du eine Zeile hinzu, mit dem Papierkorb-Symbol einer Zeile + entfernst du sie +- Positive Werte verschieben Rechts-nach-links-Durchgänge nach rechts, negative nach links +- Zeilen werden nach Geschwindigkeit sortiert gehalten; haben zwei Zeilen dieselbe Geschwindigkeit, + zählt die letzte + +Läuft ein Gravurschritt, schlägt Rayforge die Geschwindigkeit des Schritts in dieser Tabelle nach +und interpoliert den Offset linear zwischen den benachbarten Zeilen. Geschwindigkeiten unterhalb der +ersten oder oberhalb der letzten Zeile verwenden den Offset der jeweils nächsten Zeile, und eine +leere Tabelle bedeutet, dass jede Geschwindigkeit einen Offset von 0 verwendet. + +**Ohne Korrektur** – die Fehlausrichtung wächst mit der Gravurgeschwindigkeit: + +![Fehlausgerichtete Rasterdurchgänge bei 1000, 4000 und 8000 mm/min](/images/bidir-scan-offset-uncorrected.webp) + +**Mit angewandter Offset-Tabelle:** + +![Ausgerichtete Rasterdurchgänge bei jeder Geschwindigkeit](/images/bidir-scan-offset-table-applied.webp) + +Die Tabelle gilt nur für Gravurschritte, deren eigener **Bidirektionaler Scan-Offset** auf 0 steht; +ein nicht nuller Offset am Schritt hat immer Vorrang. Unter +[Bidirektionaler Scan-Offset](../features/operations/engrave.md#bidirectional-scan-offset) erfährst +du, wie die Fehlausrichtung aussieht und wie man sie kalibriert. + ## Siehe auch - [Hardware-Einstellungen](hardware) - Achsenursprung und Umkehreinstellungen diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/engrave.md b/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/engrave.md index d55355e60..b49ac8d41 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/engrave.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/engrave.md @@ -338,15 +338,16 @@ Pequeñas diferencias mecánicas o de retardo de disparo entre las dos direccion bandas visibles en algunas máquinas. Si ves esto, calibra el **Desplazamiento de Escaneo Bidireccional** de abajo para corregirlo directamente, en lugar de perder el beneficio de velocidad. -#### Desplazamiento de Escaneo Bidireccional +#### Desplazamiento de Escaneo Bidireccional {#bidirectional-scan-offset} Corrige un sesgo fijo mecánico o de retardo de disparo entre las pasadas raster de izquierda a derecha y de derecha a izquierda, que de otro modo desalinea filas de escaneo alternas (visible como bandas, especialmente en grabados de fotos). - Se configura en milímetros, positivo o negativo dependiendo de qué dirección necesita desplazarse -- Aplica un desplazamiento constante independientemente de la velocidad; si el sesgo varía con la - velocidad, calibra para tu velocidad de grabado típica +- Un valor distinto de cero aquí siempre gana; cuando es 0, el paso recurre a la tabla + [Desplazamiento de Escaneo Bidireccional por Velocidad](../../machine/advanced.md#bidirectional-scan-offset-by-speed) + de la máquina, que interpola un desplazamiento para la velocidad de este paso - El valor predeterminado es 0 (sin corrección) **Calibrando el desplazamiento:** @@ -357,6 +358,11 @@ bandas, especialmente en grabados de fotos). 3. Ajusta el desplazamiento en pequeños incrementos (0.01-0.05mm) y vuelve a probar hasta que las filas alternas se alineen +Si la desalineación cambia notablemente entre tus velocidades de grabado habituales, un único +desplazamiento no puede corregir todos los trabajos. Deja el desplazamiento del paso en 0 y +configura en su lugar una tabla de desplazamiento dependiente de la velocidad en +[Ajustes de Máquina - Avanzado](../../machine/advanced.md#bidirectional-scan-offset-by-speed). + ## Ajustes del Láser ![Ajustes del láser](/screenshots/step-settings-engrave-laser.webp) diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/machine/advanced.md b/website/i18n/es/docusaurus-plugin-content-docs/current/machine/advanced.md index 809755032..5996d6c40 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/machine/advanced.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/machine/advanced.md @@ -58,6 +58,43 @@ comandos. Un valor más grande permite más desviación pero genera menos comand Valores típicos van de 0.01mm para trabajo de precisión a 0.1mm para procesamiento más rápido. +## Desplazamiento de Escaneo Bidireccional por Velocidad {#bidirectional-scan-offset-by-speed} + +La tabla de desplazamiento de escaneo bidireccional por velocidad corrige la desalineación +horizontal entre las pasadas raster de izquierda a derecha y de derecha a izquierda. En muchas +máquinas este sesgo no es constante: cuanto más rápido se mueve el cabezal láser, más se desvían las +pasadas de derecha a izquierda de su alineación, lo que se ve como bandas que empeoran a velocidades +de grabado más altas. + +![Desplazamiento de Escaneo Bidireccional por Velocidad](/screenshots/machine-settings-advanced-bidir-offset-table.webp) + +Cada fila de la tabla asocia una velocidad de grabado con el desplazamiento, en milímetros, que las +pasadas de derecha a izquierda necesitan a esa velocidad: + +- Usa **Añadir Fila** para añadir una fila y el botón de papelera de una fila para eliminarla +- Los valores positivos desplazan las pasadas de derecha a izquierda hacia la derecha, los negativos + hacia la izquierda +- Las filas se mantienen ordenadas por velocidad; si dos filas tienen la misma velocidad, la última + es la que vale + +Cuando se ejecuta un paso de grabado, Rayforge busca la velocidad del paso en esta tabla e interpola +el desplazamiento linealmente entre las filas circundantes. Las velocidades por debajo de la primera +fila o por encima de la última usan el desplazamiento de la fila más cercana, y una tabla vacía +significa que todas las velocidades usan un desplazamiento de 0. + +**Sin corrección** — la desalineación crece con la velocidad de grabado: + +![Pasadas raster desalineadas a 1000, 4000 y 8000 mm/min](/images/bidir-scan-offset-uncorrected.webp) + +**Con la tabla de desplazamiento aplicada:** + +![Pasadas raster alineadas a todas las velocidades](/images/bidir-scan-offset-table-applied.webp) + +La tabla solo se aplica a los pasos de grabado cuyo propio **Desplazamiento de Escaneo +Bidireccional** es 0; un desplazamiento por paso distinto de cero siempre tiene prioridad. Consulta +[Desplazamiento de Escaneo Bidireccional](../features/operations/engrave.md#bidirectional-scan-offset) +para ver cómo es la desalineación y cómo calibrarla. + ## Ver También - [Ajustes de Hardware](hardware) - Configuración de origen de ejes e inversión diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/engrave.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/engrave.md index ffd7947e1..ededc1e5a 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/engrave.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/engrave.md @@ -341,15 +341,16 @@ banding visible sur certaines machines. Si vous constatez ce phénomène, calibr Balayage Bidirectionnel** ci-dessous pour le corriger directement, plutôt que de perdre l'avantage de vitesse. -#### Décalage de Balayage Bidirectionnel +#### Décalage de Balayage Bidirectionnel {#bidirectional-scan-offset} Corrige un décalage fixe mécanique ou de délai de tir entre les passes raster de gauche à droite et de droite à gauche, qui autrement désalignerait les rangées de balayage alternées (visible sous forme de banding, en particulier dans les gravures photo). - Défini en millimètres, positif ou négatif selon la direction qui doit être décalée -- Applique un décalage constant quelle que soit la vitesse ; si le décalage varie avec la vitesse, - calibrez pour votre vitesse de gravure typique +- Une valeur non nulle ici gagne toujours ; lorsqu'elle vaut 0, l'étape revient au tableau + [Décalage de Balayage Bidirectionnel par Vitesse](../../machine/advanced.md#bidirectional-scan-offset-by-speed) + de la machine, qui interpole un décalage pour la vitesse de cette étape - Par défaut à 0 (aucune correction) **Calibrage du décalage :** @@ -360,6 +361,11 @@ forme de banding, en particulier dans les gravures photo). 3. Ajustez le décalage par petits incréments (0.01-0.05mm) et retestez jusqu'à ce que les rangées alternées s'alignent +Si le désalignement varie nettement entre vos vitesses de gravure habituelles, un seul décalage ne +peut pas corriger tous les travaux. Laissez le décalage de l'étape à 0 et saisissez plutôt un +tableau de décalage dépendant de la vitesse dans +[Paramètres Machine - Avancé](../../machine/advanced.md#bidirectional-scan-offset-by-speed). + ## Paramètres Laser ![Paramètres laser](/screenshots/step-settings-engrave-laser.webp) diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/machine/advanced.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/machine/advanced.md index 9980eaeb0..531f51937 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/machine/advanced.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/machine/advanced.md @@ -62,6 +62,44 @@ génère moins de commandes. Les valeurs typiques vont de 0,01mm pour un travail de précision à 0,1mm pour un traitement plus rapide. +## Décalage de Balayage Bidirectionnel par Vitesse {#bidirectional-scan-offset-by-speed} + +Le tableau de décalage de balayage bidirectionnel par vitesse corrige le désalignement horizontal +entre les passes raster de gauche à droite et de droite à gauche. Sur beaucoup de machines, ce +décalage n'est pas constant : plus la tête laser se déplace vite, plus les passes de droite à gauche +dérivent hors de l'alignement, ce qui se manifeste par un banding qui s'aggrave aux vitesses de +gravure élevées. + +![Décalage de Balayage Bidirectionnel par Vitesse](/screenshots/machine-settings-advanced-bidir-offset-table.webp) + +Chaque ligne du tableau associe une vitesse de gravure au décalage, en millimètres, dont les passes +de droite à gauche ont besoin à cette vitesse : + +- Utilisez **Ajouter une Ligne** pour ajouter une ligne et le bouton de corbeille d'une ligne pour + la supprimer +- Les valeurs positives décalent les passes de droite à gauche vers la droite, les valeurs négatives + vers la gauche +- Les lignes sont conservées triées par vitesse ; si deux lignes ont la même vitesse, la dernière + gagne + +Lorsqu'une étape de gravure s'exécute, Rayforge y recherche la vitesse de l'étape et interpole le +décalage linéairement entre les lignes adjacentes. Les vitesses inférieures à la première ligne ou +supérieures à la dernière utilisent le décalage de la ligne la plus proche, et un tableau vide +signifie que toutes les vitesses utilisent un décalage de 0. + +**Sans correction** — le désalignement augmente avec la vitesse de gravure : + +![Passes raster désalignées à 1000, 4000 et 8000 mm/min](/images/bidir-scan-offset-uncorrected.webp) + +**Avec le tableau de décalage appliqué :** + +![Passes raster alignées à toutes les vitesses](/images/bidir-scan-offset-table-applied.webp) + +Le tableau ne s'applique qu'aux étapes de gravure dont le propre **Décalage de Balayage +Bidirectionnel** vaut 0 ; un décalage par étape non nul a toujours la priorité. Consultez +[Décalage de Balayage Bidirectionnel](../features/operations/engrave.md#bidirectional-scan-offset) +pour voir à quoi ressemble le désalignement et comment le calibrer. + ## Voir Aussi - [Paramètres Matériels](hardware) - Configuration de l'origine des axes et inversion diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/engrave.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/engrave.md index 675d3014d..630eb0469 100644 --- a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/engrave.md +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/engrave.md @@ -310,7 +310,8 @@ _Step Settings_ टैब के **उत्कीर्णन** और **शक :::tip[रेज़ोल्यूशन मेल] छवियों के लिए, रेखा अंतराल छवि रेज़ोल्यूशन से मेल खाना चाहिए या उससे अधिक बारीक होना -चाहिए. यदि आपकी छवि 10 पिक्सेल/mm (254 DPI) है, तो 0.1mm रेखा अंतराल या छोटा उपयोग करें.::: +चाहिए. यदि आपकी छवि 10 पिक्सेल/mm (254 DPI) है, तो 0.1mm रेखा अंतराल या छोटा उपयोग करें. +::: #### स्कैन दिशा @@ -344,8 +345,9 @@ Rayforge हमेशा द्विदिशात्मक स्कैन दिखाई देता है, विशेष रूप से फ़ोटो उत्कीर्णनों में). - मिलीमीटर में सेट, धनात्मक या ऋणात्मक इस पर निर्भर कि किस दिशा को खिसकाना है -- गति चाहे जो हो एक स्थिर खिसकाव लागू करता है; यदि तिरछापन गति के साथ बदलता है, तो अपनी सामान्य - उत्कीर्णन गति के लिए कैलिब्रेट करें +- यहाँ शून्येतर मान हमेशा वरीयता रखता है; जब यह 0 होता है, तो चरण मशीन की + [गति के अनुसार द्विदिशात्मक स्कैन ऑफ़सेट](../../machine/advanced.md#bidirectional-scan-offset-by-speed) + तालिका का उपयोग करता है, जो इस चरण की गति के लिए एक ऑफ़सेट इंटरपोलेट करती है - डिफ़ॉल्ट 0 (कोई सुधार नहीं) **ऑफ़सेट कैलिब्रेट करना:** @@ -356,6 +358,11 @@ Rayforge हमेशा द्विदिशात्मक स्कैन 3. ऑफ़सेट को छोटी वृद्धि (0.01-0.05mm) में समायोजित करें और बारी-बारी पंक्तियाँ संरेखित होने तक पुनः परीक्षण करें +यदि गलत संरेखण आपकी सामान्य ग्रेविंग गतियों में स्पष्ट रूप से बदलता है, तो एक अकेला ऑफ़सेट हर जॉब को +ठीक नहीं कर सकता. चरण का ऑफ़सेट 0 पर छोड़ दें और इसके बजाय +[मशीन सेटिंग्स - उन्नत](../../machine/advanced.md#bidirectional-scan-offset-by-speed) में एक +गति-निर्भर ऑफ़सेट तालिका भरें. + ## लेज़र सेटिंग्स ![लेज़र सेटिंग्स](/screenshots/step-settings-engrave-laser.webp) diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/machine/advanced.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/machine/advanced.md index 5a47b226d..2f490c9ba 100644 --- a/website/i18n/hi/docusaurus-plugin-content-docs/current/machine/advanced.md +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/machine/advanced.md @@ -60,6 +60,41 @@ description: सामान्य मान परिशुद्धता कार्य के लिए 0.01mm से तेज़ प्रोसेसिंग के लिए 0.1mm तक होते हैं. +## गति के अनुसार द्विदिशात्मक स्कैन ऑफ़सेट {#bidirectional-scan-offset-by-speed} + +गति के अनुसार द्विदिशात्मक स्कैन ऑफ़सेट की तालिका बाएँ-से-दाएँ और दाएँ-से-बाएँ रैस्टर पास के बीच +क्षैतिज गलत संरेखण को ठीक करती है. कई मशीनों पर यह विचलन स्थिर नहीं होता: लेज़र हेड जितनी तेज़ चलता +है, दाएँ-से-बाएँ पास उतना ही अधिक संरेखण से हट जाते हैं, जो बैंडिंग के रूप में दिखाई देता है और अधिक +ग्रेविंग गति पर बदतर होता जाता है. + +![गति के अनुसार द्विदिशात्मक स्कैन ऑफ़सेट](/screenshots/machine-settings-advanced-bidir-offset-table.webp) + +तालिका की प्रत्येक पंक्ति एक ग्रेविंग गति को उस गति पर दाएँ-से-बाएँ पास के लिए आवश्यक ऑफ़सेट +(मिलीमीटर में) के साथ जोड़ती है: + +- पंक्ति जोड़ने के लिए **पंक्ति जोड़ें** का और हटाने के लिए पंक्ति का कचरा बटन उपयोग करें +- धनात्मक मान दाएँ-से-बाएँ पास को दाईं ओर और ऋणात्मक मान बाईं ओर खिसकाते हैं +- पंक्तियाँ गति के अनुसार क्रमबद्ध रहती हैं; यदि दो पंक्तियों की गति समान है, तो अंतिम वाली मानी + जाती है + +जब कोई ग्रेविंग चरण चलता है, तो Rayforge चरण की गति को इस तालिका में देखता है और आस-पास की पंक्तियों +के बीच ऑफ़सेट को रैखिक रूप से इंटरपोलेट करता है. पहली पंक्ति से नीचे या अंतिम पंक्ति से ऊपर की +गतियाँ निकटतम पंक्ति का ऑफ़सेट उपयोग करती हैं, और खाली तालिका का अर्थ है कि हर गति 0 ऑफ़सेट उपयोग +करती है. + +**बिना सुधार के** — गलत संरेखण ग्रेविंग गति के साथ बढ़ता है: + +![1000, 4000 और 8000 mm/min पर गलत संरेखित रैस्टर पास](/images/bidir-scan-offset-uncorrected.webp) + +**ऑफ़सेट तालिका लागू होने पर:** + +![हर गति पर संरेखित रैस्टर पास](/images/bidir-scan-offset-table-applied.webp) + +यह तालिका केवल उन ग्रेविंग चरणों पर लागू होती है जिनका अपना **द्विदिशात्मक स्कैन ऑफ़सेट** 0 है; +शून्येतर चरण-स्तरीय ऑफ़सेट को हमेशा प्राथमिकता मिलती है. गलत संरेखण कैसा दिखता है और इसे कैसे +कैलिब्रेट करें, इसके लिए +[द्विदिशात्मक स्कैन ऑफ़सेट](../features/operations/engrave.md#bidirectional-scan-offset) देखें. + ## यह भी देखें - [हार्डवेयर सेटिंग्स](hardware) - अक्ष मूल और फ़्लिप सेटिंग्स diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/engrave.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/engrave.md index 05d9c7035..ba999f39c 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/engrave.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/engrave.md @@ -337,15 +337,16 @@ Pequenas diferenças mecânicas ou de atraso de disparo entre as duas direções visíveis em algumas máquinas. Se você vir isso, calibre o **Deslocamento de Varredura Bidirecional** abaixo para corrigir diretamente, em vez de perder o benefício de velocidade. -#### Deslocamento de Varredura Bidirecional +#### Deslocamento de Varredura Bidirecional {#bidirectional-scan-offset} Corrige um desalinhamento fixo mecânico ou de atraso de disparo entre passagens raster da esquerda para a direita e da direita para a esquerda, que de outra forma desalinha linhas de varredura alternadas (visível como faixas, especialmente em gravações de fotos). - Defina em milímetros, positivo ou negativo dependendo de qual direção precisa ser deslocada -- Aplica um deslocamento constante independentemente da velocidade; se o desalinhamento varia com a - velocidade, calibre para sua velocidade de gravação típica +- Um valor diferente de zero aqui sempre prevalece; quando é 0, a etapa recorre à tabela + [Deslocamento de Varredura Bidirecional por Velocidade](../../machine/advanced.md#bidirectional-scan-offset-by-speed) + da máquina, que interpola um deslocamento para a velocidade desta etapa - Padrão é 0 (sem correção) **Calibrando o deslocamento:** @@ -356,6 +357,11 @@ alternadas (visível como faixas, especialmente em gravações de fotos). 3. Ajuste o deslocamento em pequenos incrementos (0.01-0.05mm) e teste novamente até que as linhas alternadas se alinhem +Se o desalinhamento varia notavelmente entre suas velocidades de gravação habituais, um único +deslocamento não consegue corrigir todos os trabalhos. Deixe o deslocamento da etapa em 0 e preencha +em vez disso uma tabela de deslocamento dependente da velocidade em +[Configurações da Máquina - Avançado](../../machine/advanced.md#bidirectional-scan-offset-by-speed). + ## Configurações do Laser ![Configurações do laser](/screenshots/step-settings-engrave-laser.webp) diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/machine/advanced.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/machine/advanced.md index 2b9407eef..6e4ec8c5b 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/machine/advanced.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/machine/advanced.md @@ -57,6 +57,43 @@ comandos. Um valor maior permite mais desvio mas gera menos comandos. Valores típicos variam de 0.01mm para trabalho de precisão a 0.1mm para processamento mais rápido. +## Deslocamento de Varredura Bidirecional por Velocidade {#bidirectional-scan-offset-by-speed} + +A tabela de deslocamento de varredura bidirecional por velocidade corrige o desalinhamento +horizontal entre passagens raster da esquerda para a direita e da direita para a esquerda. Em muitas +máquinas esse desalinhamento não é constante: quanto mais rápido o cabeçote do laser se move, mais +as passagens da direita para a esquerda saem do alinhamento, o que aparece como faixas que pioram em +velocidades de gravação mais altas. + +![Deslocamento de Varredura Bidirecional por Velocidade](/screenshots/machine-settings-advanced-bidir-offset-table.webp) + +Cada linha da tabela associa uma velocidade de gravação ao deslocamento, em milímetros, de que as +passagens da direita para a esquerda precisam nessa velocidade: + +- Use **Adicionar Linha** para adicionar uma linha e o botão de lixeira de uma linha para removê-la +- Valores positivos deslocam as passagens da direita para a esquerda para a direita; valores + negativos, para a esquerda +- As linhas são mantidas ordenadas por velocidade; se duas linhas tiverem a mesma velocidade, a + última prevalece + +Quando uma etapa de gravação é executada, o Rayforge consulta a velocidade da etapa nesta tabela e +interpola o deslocamento linearmente entre as linhas vizinhas. Velocidades abaixo da primeira linha +ou acima da última usam o deslocamento da linha mais próxima, e uma tabela vazia significa que todas +as velocidades usam um deslocamento de 0. + +**Sem correção** — o desalinhamento cresce com a velocidade de gravação: + +![Passagens raster desalinhadas a 1000, 4000 e 8000 mm/min](/images/bidir-scan-offset-uncorrected.webp) + +**Com a tabela de deslocamento aplicada:** + +![Passagens raster alinhadas em todas as velocidades](/images/bidir-scan-offset-table-applied.webp) + +A tabela se aplica apenas a etapas de gravação cujo próprio **Deslocamento de Varredura +Bidirecional** é 0; um deslocamento por etapa diferente de zero sempre tem precedência. Consulte +[Deslocamento de Varredura Bidirecional](../features/operations/engrave.md#bidirectional-scan-offset) +para ver como fica o desalinhamento e como calibrá-lo. + ## Veja Também - [Configurações de Hardware](hardware) - Configuração de origem dos eixos e inversão diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/engrave.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/engrave.md index 4e960182c..43dde47da 100644 --- a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/engrave.md +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/engrave.md @@ -336,7 +336,7 @@ Rayforge завжди сканує двонаправлено (зліва нап двонаправленого сканування** нижче, щоб виправити це безпосередньо, а не втрачати перевагу у швидкості. -#### Зміщення двонаправленого сканування +#### Зміщення двонаправленого сканування {#bidirectional-scan-offset} Виправляє фіксоване механічне зміщення або зміщення затримки стрільби між растровими проходами зліва направо та справа наліво, яке інакше роз'єднує альтернативні рядки сканування (видно як смугастість, @@ -344,8 +344,9 @@ Rayforge завжди сканує двонаправлено (зліва нап - Встановлюється в міліметрах, позитивне або негативне залежно від того, який напрямок потрібно змістити -- Застосовує постійне зміщення незалежно від швидкості; якщо зміщення залежить від швидкості, - калібруйте для вашої типової швидкості гравіювання +- Ненульове значення тут завжди має перевагу; коли воно дорівнює 0, крок звертається до таблиці + [Зміщення двонаправленого сканування за швидкістю](../../machine/advanced.md#bidirectional-scan-offset-by-speed) + машини, яка інтерполює зміщення для швидкості цього кроку - За замовчуванням 0 (без корекції) **Калібрування зміщення:** @@ -356,6 +357,11 @@ Rayforge завжди сканує двонаправлено (зліва нап 3. Коригуйте зміщення малими кроками (0.01-0.05мм) і повторюйте тест, поки альтернативні рядки не вирівняються +Якщо невідповідність помітно змінюється між вашими звичайними швидкостями гравіювання, одне зміщення +не виправить усі завдання. Залиште зміщення кроку на 0 і натомість введіть таблицю зміщень, що +залежить від швидкості, у +[Налаштування машини - Розширені](../../machine/advanced.md#bidirectional-scan-offset-by-speed). + ## Налаштування лазера ![Налаштування лазера](/screenshots/step-settings-engrave-laser.webp) diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/machine/advanced.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/machine/advanced.md index 7c00414b5..1c2ffd0a8 100644 --- a/website/i18n/uk/docusaurus-plugin-content-docs/current/machine/advanced.md +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/machine/advanced.md @@ -55,6 +55,41 @@ Типові значення варіюються від 0.01мм для точної роботи до 0.1мм для швидшої обробки. +## Зміщення двонаправленого сканування за швидкістю {#bidirectional-scan-offset-by-speed} + +Таблиця зміщення двонаправленого сканування за швидкістю виправляє горизонтальне невідповідність між +растровими проходами зліва направо та справа наліво. На багатьох машинах це зміщення не є сталим: +чим швидше рухається лазерна головка, тим сильніше проходи справа наліво відхиляються від +вирівнювання, що проявляється як смугастість, яка гіршає на вищих швидкостях гравіювання. + +![Зміщення двонаправленого сканування за швидкістю](/screenshots/machine-settings-advanced-bidir-offset-table.webp) + +Кожен рядок таблиці поєднує швидкість гравіювання зі зміщенням у міліметрах, якого проходи справа +наліво потребують на цій швидкості: + +- Використовуйте **Додати Рядок**, щоб додати рядок, і кнопку кошика на рядку, щоб видалити його +- Позитивні значення зміщують проходи справа наліво вправо, негативні — вліво +- Рядки зберігаються відсортованими за швидкістю; якщо два рядки мають однакову швидкість, чинним є + останній + +Коли крок гравіювання виконується, Rayforge шукає швидкість кроку в цій таблиці та лінійно +інтерполює зміщення між сусідніми рядками. Швидкості нижче першого рядка або вище останнього +використовують зміщення найближчого рядка, а порожня таблиця означає, що всі швидкості +використовують зміщення 0. + +**Без корекції** — невідповідність зростає зі швидкістю гравіювання: + +![Невирівняні растрові проходи на 1000, 4000 та 8000 мм/хв](/images/bidir-scan-offset-uncorrected.webp) + +**Із застосованою таблицею зміщень:** + +![Вирівняні растрові проходи на кожній швидкості](/images/bidir-scan-offset-table-applied.webp) + +Таблиця застосовується лише до кроків гравіювання, власне **Зміщення двонаправленого сканування** +яких дорівнює 0; ненульове зміщення кроку завжди має перевагу. Дивіться +[Зміщення двонаправленого сканування](../features/operations/engrave.md#bidirectional-scan-offset), +щоб побачити, як виглядає невідповідність і як її калібрувати. + ## Дивіться також - [Налаштування обладнання](hardware) - Конфігурація початку осей та інверсії diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/engrave.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/engrave.md index 5bd1da9cc..c39e48630 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/engrave.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/engrave.md @@ -230,7 +230,8 @@ **Bayer 抖动**会创建规则的图案,可产生类似传统半色调印刷的艺术效果。 -**Stucki、Jarvis、Sierra、Burkes 和 Atkinson** 与 Floyd-Steinberg 一样属于误差扩散算法,但会把误差分散到更大的邻域,在肤色等平坦区域中颗粒更平滑。Atkinson 只扩散部分误差,因此对比度更高。启用**蛇形扫描**可在每一行交替扫描方向,避免方向性条纹。 +**Stucki、Jarvis、Sierra、Burkes 和 Atkinson** +与 Floyd-Steinberg 一样属于误差扩散算法,但会把误差分散到更大的邻域,在肤色等平坦区域中颗粒更平滑。Atkinson 只扩散部分误差,因此对比度更高。启用**蛇形扫描**可在每一行交替扫描方向,避免方向性条纹。 **报纸印刷**和**半色调**会把像素聚集成网点。半色调在网格上绘制圆形网点,网格由**半色调网格尺寸**(网点中心之间的距离)和**半色调角度**决定;网格尺寸应至少为激光光斑宽度的几倍。 @@ -310,12 +311,14 @@ Rayforge 始终进行双向扫描(从左到右,然后从右到左),因 两个方向之间轻微的机械或发射延迟差异可能会在某些机器上造成可见的条带。如果出现这种情况,请校准下面的**双向扫描偏移**直接进行修正,而不是失去速度优势。 -#### 双向扫描偏移 +#### 双向扫描偏移 {#bidirectional-scan-offset} 修正从左到右与从右到左光栅遍之间的固定机械或发射延迟偏斜,否则会使交替的扫描行错位(显示为条带,尤其是在照片雕刻中)。 - 以毫米为单位设置,正值或负值取决于需要移动的方向 -- 无论速度如何均施加恒定的偏移;如果偏斜随速度变化,请以您的典型雕刻速度进行校准 +- 此处非零的值始终优先;当其为 0 时,步骤会退回到机器的 + [按速度设置的双向扫描偏移](../../machine/advanced.md#bidirectional-scan-offset-by-speed) + 表,该表会为此步骤的速度插值出一个偏移 - 默认为 0(不修正) **校准偏移:** @@ -324,6 +327,10 @@ Rayforge 始终进行双向扫描(从左到右,然后从右到左),因 2. 比较交替行以确定错位的方向和程度 3. 以小增量(0.01-0.05mm)调整偏移并重新测试,直到交替行对齐 +如果错位在您的常用雕刻速度之间变化明显,单一偏移无法修正所有作业。请将步骤偏移保持为 0,改为在 +[机器设置 - 高级](../../machine/advanced.md#bidirectional-scan-offset-by-speed) +中填写随速度变化的偏移表。 + ## 激光设置 ![激光设置](/screenshots/step-settings-engrave-laser.webp) diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/machine/advanced.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/machine/advanced.md index 18ff9f876..e393c0e0f 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/machine/advanced.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/machine/advanced.md @@ -46,6 +46,30 @@ 典型值从精密工作的 0.01mm 到更快处理的 0.1mm。 +## 按速度设置的双向扫描偏移 {#bidirectional-scan-offset-by-speed} + +按速度设置的双向扫描偏移表用于修正从左到右与从右到左光栅遍之间的水平错位。在许多机器上,这种偏斜并不是恒定的:激光头移动得越快,从右到左的遍就偏离对齐得越多,表现为条带,并且雕刻速度越高越严重。 + +![按速度设置的双向扫描偏移](/screenshots/machine-settings-advanced-bidir-offset-table.webp) + +表格的每一行将一个雕刻速度与从右到左的遍在该速度下所需的偏移(以毫米为单位)配对: + +- 使用**添加行**添加一行,点击行上的垃圾桶按钮将其删除 +- 正值将从右到左的遍向右移动,负值向左移动 +- 行会按速度保持排序;如果两行的速度相同,以最后一行为准 + +当雕刻步骤运行时,Rayforge 会在该表中查找步骤的速度,并在相邻行之间线性插值偏移。低于第一行或高于最后一行的速度使用最近一行的偏移,空表表示所有速度都使用 0 偏移。 + +**未修正** —— 错位随雕刻速度增大: + +![在 1000、4000 和 8000 mm/min 下错位的光栅遍](/images/bidir-scan-offset-uncorrected.webp) + +**应用偏移表后:** + +![所有速度下都对齐的光栅遍](/images/bidir-scan-offset-table-applied.webp) + +该表仅适用于自身**双向扫描偏移**为 0 的雕刻步骤;非零的步骤级偏移始终优先。关于错位的外观以及如何校准,请参阅[双向扫描偏移](../features/operations/engrave.md#bidirectional-scan-offset)。 + ## 另请参阅 - [硬件设置](hardware) - 轴原点和翻转设置 From c54944799b06a5f2202cd9a0e2a26b3945b84eb9 Mon Sep 17 00:00:00 2001 From: Samuel Abels Date: Mon, 12 Oct 2026 01:14:07 +0200 Subject: [PATCH 048/104] docs: add the bidirectional scan offset diagram images --- .../images/bidir-scan-offset-table-applied.webp | Bin 0 -> 1214 bytes .../images/bidir-scan-offset-uncorrected.webp | Bin 0 -> 1254 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 website/static/images/bidir-scan-offset-table-applied.webp create mode 100644 website/static/images/bidir-scan-offset-uncorrected.webp diff --git a/website/static/images/bidir-scan-offset-table-applied.webp b/website/static/images/bidir-scan-offset-table-applied.webp new file mode 100644 index 0000000000000000000000000000000000000000..9017c7fac709caf154a46521fde9128efbaff5d8 GIT binary patch literal 1214 zcmV;v1VQ^!Nk&Gt1ONb6MM6+kP&iDf1ONapv%@+7$KA!k^83kI1OVQR z4JZJK*v&SI!3+j$Lzsf=D{|`3cp#M50CPDE#*Ian}5w;`i^doRUc%zkYz)odRUc#OA_M<1UAi zHWwDL2az3jIh6moFc-{)Wyf6(G5fW>RI|CTnYPPemd%Cb#$68c>+I9-)P~+=}|Re6=@J?4s9ks^Wu)_B=;K#FbF82BI8? zv-uS$H{CmEVJ){p*HJf(?sFZd7T$|*a@O?3-(FP*BoE_jvIe3YN0W(Vl%xEj3N5VV zRs+st`A0v+1=qZeQ+MomfX@LS;&PL$fhdPS9Tv5&UQMP$#?a;NV*^6;ytoWBqNfzg zbt>zZZkKC4JmGuIIcQ-mw_33dv^iLF$Llz~=*CpgW)P#u8i;a8bR#0}rJo@pRvHkZ zV|KuR9s(VRh<^$^JMDv@!CG!5Tuo>AMLxp!Zh0N278B@e!-8344MaI4ZdxLuyYaLq zC8*~2m?0qmaetZ&=ppd)UUb+q?DFW8oW$8+F1KAJK2x~`{TL70^kBR@x(C2ip0k-@;mMS+p$y+Lo&z*Sn5Wmr2ykEC9UJDOe2yq6VTI zhvI!?p(TvvR^l=0=85ZF$EgmqEZiJ^j{zWQAWLv4`B+FxSj(-r&&${6leKQ@76Z+P cQy=*E@IJR#h?OqR=9Z8hA!k^83kI1OVQR z4JZJK*v&SI!3+j$Lzsf=D{|`3cp#M50C!_2HZz_rCzavPJ6r~2&CJJWCI^?%8s{UZN;e zFevwIIbcs949YcI4o(wAqk?kHmV;CA5GdDdIW`p$fpX0j&En8hOa#g`TQrL!Q&ABp z*K9e@ij+uxqZ7n8}?oG?b7Ds!c;q!LuK5z zt2%~ecgR1B-?wj9Z47O0M0GrtgJ%1Q)W%S9Z-Ux*EQiE>Z7(f*->%YoEC)w--#+A! zEviLj+_$SbhLZbseKsgD`gYa8P;GAl#rE3!X`dg1X1AtdhGu&cBrY`Dx4Z8O>U?c4 z-E7~kpB9?!wWrwkUP85~nEQ6s#?a>D!fNBO95mYls5*v{H=;Hk%R#gKL~3Jb^KoI- z@mLO;eQhsYZr`p#dn|{5^(OGznj7G~H;Ic@zHe85`}`Qb+5eo6fA&ArB#4usY_lbX zlb~F)MYaEzGXY!>49YcIB>VrbBiYjaqy0zwkI>MJtu$n-_{26o-^0M|A8&4CvNJH_ z*w2+$5u79a9R`HUR>tM~-(D>=y7V~CRiY4aFDDTZ@j)^>12c~8JiQK_tH~D_5G`BD zkI_{9-Z_qQi^#j@sveoi{rRmku5c00vNJH_*iAXMaE{h59T*TTTYb1y5u14&@3`|g z&fVI>Av_5HXt_740Z-)92S#^h_6aNAtJV02x4M;&O#mrV~B`< z>%u#u^I#xaw(`Dhmc^tW!Hb?ej&qAOj1CdN3U&r&92P%@(lRIU<5W>X*Pj(jK>%|8 z4p_*;;Mal7loL1+)@k~X6GFIbRj(o9`!i9Vw8brUenE%}Si{c1jKktQ0OaA57ywv) zZ7B%A@ZU@ec^F(Y9ojo#{a&1B-4H^wY(d|1gWprBp0p(bGtCAOI0FVFe=P+8nBTAx zkU?{ZrN`l%Ytvvtv~0EDYGr)Z;Xg~Bw8bfGzHQS0M&WpfTWBc=B>gyUEnqXYGSVzQ z3FBnr-a05$wqO=>!Kf+B^`b3KVat7pw$g?rT%&-6P}zbNGt*e+K3g0o@0a;OxpV9c z%sBQk@)&sM&%8vmYz2%ZamJEw3GO?NbC-2A)iwZ}HYIqN0?Z7|IJOe`F2G9!%U1q- zG}STp9mly24Bh$h_GT6UD+4=%t<+*YFA*(UIalw0|GeJqQCk93mp(?p|4a7S5+NQ; QNH$v}TiSoL|LjW!03jG&MgRZ+ literal 0 HcmV?d00001 From 260cf5c9512fd48da85926a9da733228b5558942 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Fri, 9 Oct 2026 13:31:17 +0200 Subject: [PATCH 049/104] feat(post): add a Perforation post-processor for Contour steps Cuts in dashes: the laser fires for a cut length and is off for a skip length along every contour, restarting with a full cut at the start of each contour. The gap centres are placed in millimetres along the workpiece's contours and handed to the existing tabs gap transform, so no new raygeo code is needed. Off by default in new and loaded Contour steps. Refs #525 --- .../laser_essentials/steps/contour_step.py | 5 + .../tests/steps/test_contour_step.py | 17 +- .../locale/de/LC_MESSAGES/post_processors.po | 36 ++++ .../locale/en/LC_MESSAGES/post_processors.po | 33 ++++ .../locale/es/LC_MESSAGES/post_processors.po | 35 ++++ .../locale/fr/LC_MESSAGES/post_processors.po | 35 ++++ .../locale/hi/LC_MESSAGES/post_processors.po | 33 ++++ .../locale/post_processors.pot | 33 ++++ .../locale/pt/LC_MESSAGES/post_processors.po | 35 ++++ .../locale/uk/LC_MESSAGES/post_processors.po | 34 ++++ .../zh_CN/LC_MESSAGES/post_processors.po | 33 ++++ .../post_processors/transformers/__init__.py | 2 + .../transformers/perforation_transformer.py | 139 +++++++++++++ .../post_processors/widgets/__init__.py | 3 + .../widgets/perforation_group.py | 69 +++++++ .../post_processors/worker.py | 4 + .../test_perforation_transformer.py | 185 ++++++++++++++++++ .../tests/ui_gtk/test_perforation_group.py | 56 ++++++ 18 files changed, 785 insertions(+), 2 deletions(-) create mode 100644 rayforge/builtin_addons/rayforge-addon-post/post_processors/transformers/perforation_transformer.py create mode 100644 rayforge/builtin_addons/rayforge-addon-post/post_processors/widgets/perforation_group.py create mode 100644 rayforge/builtin_addons/rayforge-addon-post/tests/transformers/test_perforation_transformer.py create mode 100644 rayforge/builtin_addons/rayforge-addon-post/tests/ui_gtk/test_perforation_group.py diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/steps/contour_step.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/steps/contour_step.py index 9eddb44c2..4783fa67e 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/steps/contour_step.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/steps/contour_step.py @@ -260,6 +260,9 @@ def get_default_transformers_dicts(cls) -> tuple[list, list]: Smooth = transformer_registry.get("Smooth") LeadInOutTransformer = transformer_registry.get("LeadInOutTransformer") TabOpsTransformer = transformer_registry.get("TabOpsTransformer") + PerforationTransformer = transformer_registry.get( + "PerforationTransformer" + ) CropTransformer = transformer_registry.get("CropTransformer") MergeLinesTransformer = transformer_registry.get( "MergeLinesTransformer" @@ -269,6 +272,7 @@ def get_default_transformers_dicts(cls) -> tuple[list, list]: assert Smooth is not None assert LeadInOutTransformer is not None assert TabOpsTransformer is not None + assert PerforationTransformer is not None assert CropTransformer is not None assert MergeLinesTransformer is not None assert Optimize is not None @@ -280,6 +284,7 @@ def get_default_transformers_dicts(cls) -> tuple[list, list]: enabled=False, lead_in_mm=0, lead_out_mm=0, auto=True ).to_dict(), TabOpsTransformer().to_dict(), + PerforationTransformer(enabled=False).to_dict(), CropTransformer(enabled=False).to_dict(), optimize_dict, ], [ diff --git a/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_contour_step.py b/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_contour_step.py index 1b104c01e..eb9cf3f8d 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_contour_step.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_contour_step.py @@ -37,13 +37,25 @@ def test_create(self, mock_context): step = ContourStep.create(mock_context, name="Created") assert isinstance(step, ContourStep) assert step.name == "Created" - assert len(step.per_workpiece_transformers_dicts) == 5 + assert len(step.per_workpiece_transformers_dicts) == 6 assert len(step.per_step_transformers_dicts) == 3 assert step.selected_head_uid == "test-laser-uid" def test_create_without_optimize(self, mock_context): step = ContourStep.create(mock_context, optimize=False) - assert len(step.per_workpiece_transformers_dicts) == 4 + assert len(step.per_workpiece_transformers_dicts) == 5 + + def test_create_adds_disabled_perforation_after_tabs(self, mock_context): + step = ContourStep.create(mock_context) + names = [t["name"] for t in step.per_workpiece_transformers_dicts] + assert "PerforationTransformer" in names + assert names.index("PerforationTransformer") == ( + names.index("TabOpsTransformer") + 1 + ) + perforation = step.per_workpiece_transformers_dicts[ + names.index("PerforationTransformer") + ] + assert perforation["enabled"] is False def test_serialization_includes_step_type(self): step = ContourStep(name="Test") @@ -88,6 +100,7 @@ def test_from_dict_adds_new_transformers_from_old_project(self): t["name"] for t in restored.per_workpiece_transformers_dicts ] assert "TabOpsTransformer" in wp_names + assert "PerforationTransformer" in wp_names assert "Smooth" in wp_names assert "CropTransformer" in wp_names assert "Optimize" in wp_names diff --git a/rayforge/builtin_addons/rayforge-addon-post/locale/de/LC_MESSAGES/post_processors.po b/rayforge/builtin_addons/rayforge-addon-post/locale/de/LC_MESSAGES/post_processors.po index d181b503b..12557fe31 100644 --- a/rayforge/builtin_addons/rayforge-addon-post/locale/de/LC_MESSAGES/post_processors.po +++ b/rayforge/builtin_addons/rayforge-addon-post/locale/de/LC_MESSAGES/post_processors.po @@ -298,3 +298,39 @@ msgid "Creates holding tabs by adding gaps or reducing power on cut paths" msgstr "" "Erstellt Haltestege durch Hinzufügen von Lücken oder Reduzierung der " "Leistung auf Schneidpfaden" + +#: post_processors/transformers/perforation_transformer.py +msgid "Perforation" +msgstr "Perforation" + +#: post_processors/transformers/perforation_transformer.py +msgid "" +"Cuts in dashes: the laser fires for the cut length and skips the skip length" +msgstr "" +"Schneidet gestrichelt: Der Laser schneidet über die Schnittlänge und lässt " +"die Auslasslänge aus" + +#: post_processors/widgets/perforation_group.py +msgid "Cut Length" +msgstr "Schnittlänge" + +#: post_processors/widgets/perforation_group.py +msgid "Distance the laser fires before each gap" +msgstr "Strecke, die der Laser vor jeder Lücke schneidet" + +#: post_processors/widgets/perforation_group.py +msgid "Skip Length" +msgstr "Auslasslänge" + +#: post_processors/widgets/perforation_group.py +msgid "Distance travelled with the laser off between cuts" +msgstr "" +"Strecke, die zwischen den Schnitten mit ausgeschaltetem Laser gefahren wird" + +#: post_processors/widgets/perforation_group.py +msgid "Change perforation cut length" +msgstr "Perforations-Schnittlänge ändern" + +#: post_processors/widgets/perforation_group.py +msgid "Change perforation skip length" +msgstr "Perforations-Auslasslänge ändern" diff --git a/rayforge/builtin_addons/rayforge-addon-post/locale/en/LC_MESSAGES/post_processors.po b/rayforge/builtin_addons/rayforge-addon-post/locale/en/LC_MESSAGES/post_processors.po index 2b725acf4..36c33035a 100644 --- a/rayforge/builtin_addons/rayforge-addon-post/locale/en/LC_MESSAGES/post_processors.po +++ b/rayforge/builtin_addons/rayforge-addon-post/locale/en/LC_MESSAGES/post_processors.po @@ -283,3 +283,36 @@ msgstr "Tabs" #: post_processors/transformers/tabs_transformer.py msgid "Creates holding tabs by adding gaps or reducing power on cut paths" msgstr "Creates holding tabs by adding gaps or reducing power on cut paths" + +#: post_processors/transformers/perforation_transformer.py +msgid "Perforation" +msgstr "" + +#: post_processors/transformers/perforation_transformer.py +msgid "" +"Cuts in dashes: the laser fires for the cut length and skips the skip length" +msgstr "" + +#: post_processors/widgets/perforation_group.py +msgid "Cut Length" +msgstr "" + +#: post_processors/widgets/perforation_group.py +msgid "Distance the laser fires before each gap" +msgstr "" + +#: post_processors/widgets/perforation_group.py +msgid "Skip Length" +msgstr "" + +#: post_processors/widgets/perforation_group.py +msgid "Distance travelled with the laser off between cuts" +msgstr "" + +#: post_processors/widgets/perforation_group.py +msgid "Change perforation cut length" +msgstr "" + +#: post_processors/widgets/perforation_group.py +msgid "Change perforation skip length" +msgstr "" diff --git a/rayforge/builtin_addons/rayforge-addon-post/locale/es/LC_MESSAGES/post_processors.po b/rayforge/builtin_addons/rayforge-addon-post/locale/es/LC_MESSAGES/post_processors.po index cff26d78e..6274a9653 100644 --- a/rayforge/builtin_addons/rayforge-addon-post/locale/es/LC_MESSAGES/post_processors.po +++ b/rayforge/builtin_addons/rayforge-addon-post/locale/es/LC_MESSAGES/post_processors.po @@ -297,3 +297,38 @@ msgid "Creates holding tabs by adding gaps or reducing power on cut paths" msgstr "" "Crea pestañas de sujeción añadiendo huecos o reduciendo potencia en " "trayectorias de corte" + +#: post_processors/transformers/perforation_transformer.py +msgid "Perforation" +msgstr "Perforación" + +#: post_processors/transformers/perforation_transformer.py +msgid "" +"Cuts in dashes: the laser fires for the cut length and skips the skip length" +msgstr "" +"Corta en trazos: el láser dispara durante la longitud de corte y salta la " +"longitud de salto" + +#: post_processors/widgets/perforation_group.py +msgid "Cut Length" +msgstr "Longitud de corte" + +#: post_processors/widgets/perforation_group.py +msgid "Distance the laser fires before each gap" +msgstr "Distancia que dispara el láser antes de cada hueco" + +#: post_processors/widgets/perforation_group.py +msgid "Skip Length" +msgstr "Longitud de salto" + +#: post_processors/widgets/perforation_group.py +msgid "Distance travelled with the laser off between cuts" +msgstr "Distancia recorrida con el láser apagado entre cortes" + +#: post_processors/widgets/perforation_group.py +msgid "Change perforation cut length" +msgstr "Cambiar longitud de corte de perforación" + +#: post_processors/widgets/perforation_group.py +msgid "Change perforation skip length" +msgstr "Cambiar longitud de salto de perforación" diff --git a/rayforge/builtin_addons/rayforge-addon-post/locale/fr/LC_MESSAGES/post_processors.po b/rayforge/builtin_addons/rayforge-addon-post/locale/fr/LC_MESSAGES/post_processors.po index 445349d76..dd5c2375b 100644 --- a/rayforge/builtin_addons/rayforge-addon-post/locale/fr/LC_MESSAGES/post_processors.po +++ b/rayforge/builtin_addons/rayforge-addon-post/locale/fr/LC_MESSAGES/post_processors.po @@ -302,3 +302,38 @@ msgid "Creates holding tabs by adding gaps or reducing power on cut paths" msgstr "" "Crée des languettes de maintien en ajoutant des espaces ou en réduisant la " "puissance sur les parcours de coupe" + +#: post_processors/transformers/perforation_transformer.py +msgid "Perforation" +msgstr "Perforation" + +#: post_processors/transformers/perforation_transformer.py +msgid "" +"Cuts in dashes: the laser fires for the cut length and skips the skip length" +msgstr "" +"Découpe en pointillés : le laser tire sur la longueur de coupe et saute la " +"longueur de saut" + +#: post_processors/widgets/perforation_group.py +msgid "Cut Length" +msgstr "Longueur de coupe" + +#: post_processors/widgets/perforation_group.py +msgid "Distance the laser fires before each gap" +msgstr "Distance sur laquelle le laser tire avant chaque interruption" + +#: post_processors/widgets/perforation_group.py +msgid "Skip Length" +msgstr "Longueur de saut" + +#: post_processors/widgets/perforation_group.py +msgid "Distance travelled with the laser off between cuts" +msgstr "Distance parcourue laser éteint entre les coupes" + +#: post_processors/widgets/perforation_group.py +msgid "Change perforation cut length" +msgstr "Modifier la longueur de coupe de la perforation" + +#: post_processors/widgets/perforation_group.py +msgid "Change perforation skip length" +msgstr "Modifier la longueur de saut de la perforation" diff --git a/rayforge/builtin_addons/rayforge-addon-post/locale/hi/LC_MESSAGES/post_processors.po b/rayforge/builtin_addons/rayforge-addon-post/locale/hi/LC_MESSAGES/post_processors.po index db86626fb..431bcad31 100644 --- a/rayforge/builtin_addons/rayforge-addon-post/locale/hi/LC_MESSAGES/post_processors.po +++ b/rayforge/builtin_addons/rayforge-addon-post/locale/hi/LC_MESSAGES/post_processors.po @@ -285,3 +285,36 @@ msgstr "टैब" #: post_processors/transformers/tabs_transformer.py msgid "Creates holding tabs by adding gaps or reducing power on cut paths" msgstr "कट पाथ पर अंतराल जोड़कर या शक्ति घटाकर होल्डिंग टैब बनाता है" + +#: post_processors/transformers/perforation_transformer.py +msgid "Perforation" +msgstr "परफोरेशन" + +#: post_processors/transformers/perforation_transformer.py +msgid "" +"Cuts in dashes: the laser fires for the cut length and skips the skip length" +msgstr "डैश में काटता है: लेज़र कट लंबाई तक चलता है और स्किप लंबाई छोड़ देता है" + +#: post_processors/widgets/perforation_group.py +msgid "Cut Length" +msgstr "कट लंबाई" + +#: post_processors/widgets/perforation_group.py +msgid "Distance the laser fires before each gap" +msgstr "हर गैप से पहले लेज़र जितनी दूरी तक चलता है" + +#: post_processors/widgets/perforation_group.py +msgid "Skip Length" +msgstr "स्किप लंबाई" + +#: post_processors/widgets/perforation_group.py +msgid "Distance travelled with the laser off between cuts" +msgstr "कटों के बीच लेज़र बंद करके तय की गई दूरी" + +#: post_processors/widgets/perforation_group.py +msgid "Change perforation cut length" +msgstr "परफोरेशन कट लंबाई बदलें" + +#: post_processors/widgets/perforation_group.py +msgid "Change perforation skip length" +msgstr "परफोरेशन स्किप लंबाई बदलें" diff --git a/rayforge/builtin_addons/rayforge-addon-post/locale/post_processors.pot b/rayforge/builtin_addons/rayforge-addon-post/locale/post_processors.pot index 1c533abba..95b43fa9c 100644 --- a/rayforge/builtin_addons/rayforge-addon-post/locale/post_processors.pot +++ b/rayforge/builtin_addons/rayforge-addon-post/locale/post_processors.pot @@ -283,3 +283,36 @@ msgstr "" #: post_processors/transformers/tabs_transformer.py msgid "Creates holding tabs by adding gaps or reducing power on cut paths" msgstr "" + +#: post_processors/transformers/perforation_transformer.py +msgid "Perforation" +msgstr "" + +#: post_processors/transformers/perforation_transformer.py +msgid "" +"Cuts in dashes: the laser fires for the cut length and skips the skip length" +msgstr "" + +#: post_processors/widgets/perforation_group.py +msgid "Cut Length" +msgstr "" + +#: post_processors/widgets/perforation_group.py +msgid "Distance the laser fires before each gap" +msgstr "" + +#: post_processors/widgets/perforation_group.py +msgid "Skip Length" +msgstr "" + +#: post_processors/widgets/perforation_group.py +msgid "Distance travelled with the laser off between cuts" +msgstr "" + +#: post_processors/widgets/perforation_group.py +msgid "Change perforation cut length" +msgstr "" + +#: post_processors/widgets/perforation_group.py +msgid "Change perforation skip length" +msgstr "" diff --git a/rayforge/builtin_addons/rayforge-addon-post/locale/pt/LC_MESSAGES/post_processors.po b/rayforge/builtin_addons/rayforge-addon-post/locale/pt/LC_MESSAGES/post_processors.po index 4148e83f2..a0dc872f4 100644 --- a/rayforge/builtin_addons/rayforge-addon-post/locale/pt/LC_MESSAGES/post_processors.po +++ b/rayforge/builtin_addons/rayforge-addon-post/locale/pt/LC_MESSAGES/post_processors.po @@ -295,3 +295,38 @@ msgid "Creates holding tabs by adding gaps or reducing power on cut paths" msgstr "" "Cria abas de fixação adicionando lacunas ou reduzindo a potência em caminhos " "de corte" + +#: post_processors/transformers/perforation_transformer.py +msgid "Perforation" +msgstr "Perfuração" + +#: post_processors/transformers/perforation_transformer.py +msgid "" +"Cuts in dashes: the laser fires for the cut length and skips the skip length" +msgstr "" +"Corta em traços: o laser dispara pelo comprimento de corte e pula o " +"comprimento de salto" + +#: post_processors/widgets/perforation_group.py +msgid "Cut Length" +msgstr "Comprimento de corte" + +#: post_processors/widgets/perforation_group.py +msgid "Distance the laser fires before each gap" +msgstr "Distância em que o laser dispara antes de cada intervalo" + +#: post_processors/widgets/perforation_group.py +msgid "Skip Length" +msgstr "Comprimento de salto" + +#: post_processors/widgets/perforation_group.py +msgid "Distance travelled with the laser off between cuts" +msgstr "Distância percorrida com o laser desligado entre os cortes" + +#: post_processors/widgets/perforation_group.py +msgid "Change perforation cut length" +msgstr "Alterar comprimento de corte da perfuração" + +#: post_processors/widgets/perforation_group.py +msgid "Change perforation skip length" +msgstr "Alterar comprimento de salto da perfuração" diff --git a/rayforge/builtin_addons/rayforge-addon-post/locale/uk/LC_MESSAGES/post_processors.po b/rayforge/builtin_addons/rayforge-addon-post/locale/uk/LC_MESSAGES/post_processors.po index d9d336910..dfdf97cf2 100644 --- a/rayforge/builtin_addons/rayforge-addon-post/locale/uk/LC_MESSAGES/post_processors.po +++ b/rayforge/builtin_addons/rayforge-addon-post/locale/uk/LC_MESSAGES/post_processors.po @@ -295,3 +295,37 @@ msgid "Creates holding tabs by adding gaps or reducing power on cut paths" msgstr "" "Створює утримувальні закладки шляхом додавання проміжків або зменшення " "потужності на контурах різання" + +#: post_processors/transformers/perforation_transformer.py +msgid "Perforation" +msgstr "Перфорація" + +#: post_processors/transformers/perforation_transformer.py +msgid "" +"Cuts in dashes: the laser fires for the cut length and skips the skip length" +msgstr "" +"Ріже пунктиром: лазер ріже на довжині різу й пропускає довжину пропуску" + +#: post_processors/widgets/perforation_group.py +msgid "Cut Length" +msgstr "Довжина різу" + +#: post_processors/widgets/perforation_group.py +msgid "Distance the laser fires before each gap" +msgstr "Відстань, на якій лазер ріже перед кожним проміжком" + +#: post_processors/widgets/perforation_group.py +msgid "Skip Length" +msgstr "Довжина пропуску" + +#: post_processors/widgets/perforation_group.py +msgid "Distance travelled with the laser off between cuts" +msgstr "Відстань, яку лазер проходить вимкненим між різами" + +#: post_processors/widgets/perforation_group.py +msgid "Change perforation cut length" +msgstr "Змінити довжину різу перфорації" + +#: post_processors/widgets/perforation_group.py +msgid "Change perforation skip length" +msgstr "Змінити довжину пропуску перфорації" diff --git a/rayforge/builtin_addons/rayforge-addon-post/locale/zh_CN/LC_MESSAGES/post_processors.po b/rayforge/builtin_addons/rayforge-addon-post/locale/zh_CN/LC_MESSAGES/post_processors.po index 5012afa25..c76d73b95 100644 --- a/rayforge/builtin_addons/rayforge-addon-post/locale/zh_CN/LC_MESSAGES/post_processors.po +++ b/rayforge/builtin_addons/rayforge-addon-post/locale/zh_CN/LC_MESSAGES/post_processors.po @@ -282,3 +282,36 @@ msgstr "标签" #: post_processors/transformers/tabs_transformer.py msgid "Creates holding tabs by adding gaps or reducing power on cut paths" msgstr "通过添加间隙或降低切割路径上的功率来创建保持标签" + +#: post_processors/transformers/perforation_transformer.py +msgid "Perforation" +msgstr "穿孔" + +#: post_processors/transformers/perforation_transformer.py +msgid "" +"Cuts in dashes: the laser fires for the cut length and skips the skip length" +msgstr "虚线切割:激光在切割长度内出光,并跳过跳过长度" + +#: post_processors/widgets/perforation_group.py +msgid "Cut Length" +msgstr "切割长度" + +#: post_processors/widgets/perforation_group.py +msgid "Distance the laser fires before each gap" +msgstr "每个间隙前激光出光的距离" + +#: post_processors/widgets/perforation_group.py +msgid "Skip Length" +msgstr "跳过长度" + +#: post_processors/widgets/perforation_group.py +msgid "Distance travelled with the laser off between cuts" +msgstr "两段切割之间关闭激光移动的距离" + +#: post_processors/widgets/perforation_group.py +msgid "Change perforation cut length" +msgstr "更改穿孔切割长度" + +#: post_processors/widgets/perforation_group.py +msgid "Change perforation skip length" +msgstr "更改穿孔跳过长度" diff --git a/rayforge/builtin_addons/rayforge-addon-post/post_processors/transformers/__init__.py b/rayforge/builtin_addons/rayforge-addon-post/post_processors/transformers/__init__.py index 7583f8b03..9840ecb7c 100644 --- a/rayforge/builtin_addons/rayforge-addon-post/post_processors/transformers/__init__.py +++ b/rayforge/builtin_addons/rayforge-addon-post/post_processors/transformers/__init__.py @@ -12,6 +12,7 @@ from .multipass_transformer import MultiPassTransformer from .optimize_transformer import Optimize from .overscan_transformer import OverscanTransformer +from .perforation_transformer import PerforationTransformer from .smooth_transformer import Smooth from .tabs_transformer import TabOpsTransformer @@ -23,6 +24,7 @@ "MultiPassTransformer", "Optimize", "OverscanTransformer", + "PerforationTransformer", "Smooth", "TabOpsTransformer", ] diff --git a/rayforge/builtin_addons/rayforge-addon-post/post_processors/transformers/perforation_transformer.py b/rayforge/builtin_addons/rayforge-addon-post/post_processors/transformers/perforation_transformer.py new file mode 100644 index 000000000..5be0f4d66 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-post/post_processors/transformers/perforation_transformer.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +from collections.abc import Sequence +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +from raygeo.geo import Matrix +from raygeo.ops.transform.tabs import TabsSpec + +from rayforge.core.workpiece import WorkPiece +from rayforge.pipeline.transformer.base import OpsTransformer + +if TYPE_CHECKING: + from raygeo.geo import Geometry + + +MIN_LENGTH = 0.01 +EPSILON = 1e-4 + + +class PerforationTransformer(OpsTransformer): + """ + Turns cut paths into a dashed cut: the laser fires for + ``cut_length`` and is off for ``skip_length``, repeated along + every contour of the workpiece. + + The gaps are placed in millimetres along each contour of the + workpiece's boundaries, starting with a full cut at the start of + every contour, and handed to the tabs gap machinery, which cuts + them out of the nearest toolpath. + """ + + SPEC_NAME = "tabs" + DEFAULT_CUT_LENGTH = 2.0 + DEFAULT_SKIP_LENGTH = 1.0 + + def __init__( + self, + enabled: bool = True, + cut_length: float = DEFAULT_CUT_LENGTH, + skip_length: float = DEFAULT_SKIP_LENGTH, + ): + super().__init__(enabled=enabled) + self._cut_length = max(MIN_LENGTH, cut_length) + self._skip_length = max(MIN_LENGTH, skip_length) + + @property + def cut_length(self) -> float: + return self._cut_length + + @cut_length.setter + def cut_length(self, value: float) -> None: + self._cut_length = max(MIN_LENGTH, value) + self.changed.send(self) + + @property + def skip_length(self) -> float: + return self._skip_length + + @skip_length.setter + def skip_length(self, value: float) -> None: + self._skip_length = max(MIN_LENGTH, value) + self.changed.send(self) + + @property + def label(self) -> str: + return _("Perforation") + + @property + def description(self) -> str: + return _( + "Cuts in dashes: the laser fires for the cut length and " + "skips the skip length" + ) + + def _gap_centres(self, length: float) -> list[float]: + period = self._cut_length + self._skip_length + half_gap = self._skip_length / 2.0 + centres: list[float] = [] + centre = self._cut_length + half_gap + while centre + half_gap <= length + EPSILON: + centres.append(centre) + centre += period + return centres + + def _clips_for_contour( + self, contour: Geometry + ) -> list[tuple[float, float, float]]: + centres = self._gap_centres(contour.distance()) + if not centres: + return [] + positions = contour.get_positions_at_distances(centres) + return [ + (point[0], point[1], self._skip_length) + for _seg, _t, point in positions + ] + + def _generate_clips( + self, workpiece: WorkPiece + ) -> list[tuple[float, float, float]]: + boundaries = workpiece.boundaries + if boundaries is None or boundaries.is_empty(): + return [] + width, height = workpiece.size + scaled = boundaries.copy() + if width > 0 and height > 0: + scaled.transform(Matrix.scale(width, height)) + clips: list[tuple[float, float, float]] = [] + for contour in scaled.split_into_contours(): + clips.extend(self._clips_for_contour(contour)) + return clips + + def to_spec( + self, + workpiece: WorkPiece | None, + stock_geometries: Sequence[Geometry] | None, + settings: dict[str, Any] | None, + ) -> TabsSpec: + clips = self._generate_clips(workpiece) if workpiece else [] + return TabsSpec(tab_power=0.0, original_power=1.0, clips=clips) + + def to_dict(self) -> dict[str, Any]: + data = super().to_dict() + data["cut_length"] = self._cut_length + data["skip_length"] = self._skip_length + return data + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> PerforationTransformer: + if data.get("name") != cls.__name__: + raise ValueError( + f"Mismatched transformer name: expected {cls.__name__}," + f" got {data.get('name')}" + ) + return cls( + enabled=data.get("enabled", True), + cut_length=data.get("cut_length", cls.DEFAULT_CUT_LENGTH), + skip_length=data.get("skip_length", cls.DEFAULT_SKIP_LENGTH), + ) diff --git a/rayforge/builtin_addons/rayforge-addon-post/post_processors/widgets/__init__.py b/rayforge/builtin_addons/rayforge-addon-post/post_processors/widgets/__init__.py index d8facaa9a..b53c7479d 100644 --- a/rayforge/builtin_addons/rayforge-addon-post/post_processors/widgets/__init__.py +++ b/rayforge/builtin_addons/rayforge-addon-post/post_processors/widgets/__init__.py @@ -9,6 +9,7 @@ MultiPassTransformer, Optimize, OverscanTransformer, + PerforationTransformer, Smooth, ) from .crop_group import CropSettingsGroup @@ -17,6 +18,7 @@ from .multipass_group import MultiPassSettingsGroup from .optimize_group import OptimizeSettingsGroup from .overscan_group import OverscanSettingsGroup +from .perforation_group import PerforationSettingsGroup from .smooth_group import SmoothSettingsGroup TRANSFORMER_WIDGETS = { @@ -26,5 +28,6 @@ MultiPassTransformer: MultiPassSettingsGroup, Optimize: OptimizeSettingsGroup, OverscanTransformer: OverscanSettingsGroup, + PerforationTransformer: PerforationSettingsGroup, Smooth: SmoothSettingsGroup, } diff --git a/rayforge/builtin_addons/rayforge-addon-post/post_processors/widgets/perforation_group.py b/rayforge/builtin_addons/rayforge-addon-post/post_processors/widgets/perforation_group.py new file mode 100644 index 000000000..e5ecdf504 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-post/post_processors/widgets/perforation_group.py @@ -0,0 +1,69 @@ +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from rayforge.shared.util.glib import DebounceMixin +from rayforge.ui_gtk.doceditor.post_processor.groups import ( + ExpanderHost, + TransformerSettingsGroup, +) +from rayforge.ui_gtk.shared.pref_rows import LengthSpinRow + +from ..transformers import PerforationTransformer + +if TYPE_CHECKING: + from rayforge.core.step import Step + + +class PerforationSettingsGroup(DebounceMixin, TransformerSettingsGroup): + """UI for configuring the PerforationTransformer.""" + + def __init__( + self, + title: str, + transformer: PerforationTransformer, + page: ExpanderHost, + *, + step: "Step | None" = None, + **kwargs, + ): + super().__init__(title, transformer, page, step=step, **kwargs) + + self.cut_row = LengthSpinRow( + _("Cut Length"), + _("Distance the laser fires before each gap"), + lower=0.01, + upper=1000.0, + value_in_base=transformer.cut_length, + ) + self.cut_row.value_changed.connect( + lambda r: self._debounce(self._on_cut_changed, r) + ) + self.add(self.cut_row) + + self.skip_row = LengthSpinRow( + _("Skip Length"), + _("Distance travelled with the laser off between cuts"), + lower=0.01, + upper=1000.0, + value_in_base=transformer.skip_length, + ) + self.skip_row.value_changed.connect( + lambda r: self._debounce(self._on_skip_changed, r) + ) + self.add(self.skip_row) + + def _on_cut_changed(self, row: LengthSpinRow) -> None: + self.param_changed.send( + self, + key="cut_length", + value=row.get_value_in_base_units(), + name=_("Change perforation cut length"), + ) + + def _on_skip_changed(self, row: LengthSpinRow) -> None: + self.param_changed.send( + self, + key="skip_length", + value=row.get_value_in_base_units(), + name=_("Change perforation skip length"), + ) diff --git a/rayforge/builtin_addons/rayforge-addon-post/post_processors/worker.py b/rayforge/builtin_addons/rayforge-addon-post/post_processors/worker.py index 691d01aab..3a976fc9e 100644 --- a/rayforge/builtin_addons/rayforge-addon-post/post_processors/worker.py +++ b/rayforge/builtin_addons/rayforge-addon-post/post_processors/worker.py @@ -14,6 +14,7 @@ MultiPassTransformer, Optimize, OverscanTransformer, + PerforationTransformer, Smooth, TabOpsTransformer, ) @@ -33,5 +34,8 @@ def register_transformers(transformer_registry): transformer_registry.register(MultiPassTransformer, addon_name=ADDON_NAME) transformer_registry.register(Optimize, addon_name=ADDON_NAME) transformer_registry.register(OverscanTransformer, addon_name=ADDON_NAME) + transformer_registry.register( + PerforationTransformer, addon_name=ADDON_NAME + ) transformer_registry.register(Smooth, addon_name=ADDON_NAME) transformer_registry.register(TabOpsTransformer, addon_name=ADDON_NAME) diff --git a/rayforge/builtin_addons/rayforge-addon-post/tests/transformers/test_perforation_transformer.py b/rayforge/builtin_addons/rayforge-addon-post/tests/transformers/test_perforation_transformer.py new file mode 100644 index 000000000..ea3b40a44 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-post/tests/transformers/test_perforation_transformer.py @@ -0,0 +1,185 @@ +from unittest.mock import MagicMock + +import pytest +from post_processors.transformers import PerforationTransformer +from raygeo.geo import Geometry +from raygeo.ops import Ops +from raygeo.ops.transform.tabs import TabsSpec +from raygeo.ops.types import CommandType, SectionType + +from rayforge.core.workpiece import WorkPiece + + +def _unit_square(x0=0.0, y0=0.0, x1=1.0, y1=1.0) -> Geometry: + geo = Geometry() + geo.move_to(x0, y0) + geo.line_to(x1, y0) + geo.line_to(x1, y1) + geo.line_to(x0, y1) + geo.close_path() + return geo + + +def _workpiece(boundaries, size=(20.0, 20.0)): + wp = MagicMock(spec=WorkPiece) + wp.boundaries = boundaries + wp.size = size + return wp + + +def _square_ops(x0, y0, x1, y1) -> Ops: + ops = Ops() + ops.set_power(1.0) + ops.ops_section_start( + section_type=SectionType.VECTOR_OUTLINE, workpiece_uid="wp" + ) + ops.move_to(x0, y0, 0.0) + ops.line_to(x1, y0, 0.0) + ops.line_to(x1, y1, 0.0) + ops.line_to(x0, y1, 0.0) + ops.line_to(x0, y0, 0.0) + ops.ops_section_end(section_type=SectionType.VECTOR_OUTLINE) + return ops + + +def _cut_runs(ops: Ops) -> int: + return sum( + 1 + for i in range(ops.len()) + if ops.command_type(i) == CommandType.MOVE_TO + ) + + +class TestPerforationConfig: + def test_defaults(self): + t = PerforationTransformer() + assert t.enabled is True + assert t.cut_length == pytest.approx(2.0) + assert t.skip_length == pytest.approx(1.0) + + def test_label_and_description(self): + t = PerforationTransformer() + assert t.label + assert t.description + + def test_lengths_are_clamped_to_a_minimum(self): + t = PerforationTransformer(cut_length=0.0, skip_length=-1.0) + assert t.cut_length >= 0.01 + assert t.skip_length >= 0.01 + + def test_setter_sends_changed(self): + t = PerforationTransformer() + received = [] + + def on_changed(sender): + received.append(sender) + + t.changed.connect(on_changed) + t.cut_length = 3.0 + t.skip_length = 0.5 + assert len(received) == 2 + assert t.cut_length == pytest.approx(3.0) + assert t.skip_length == pytest.approx(0.5) + + def test_dict_round_trip(self): + t = PerforationTransformer( + enabled=False, cut_length=1.5, skip_length=0.5 + ) + data = t.to_dict() + assert data["name"] == "PerforationTransformer" + restored = PerforationTransformer.from_dict(data) + assert restored.enabled is False + assert restored.cut_length == pytest.approx(1.5) + assert restored.skip_length == pytest.approx(0.5) + + def test_from_dict_defaults(self): + restored = PerforationTransformer.from_dict( + {"name": "PerforationTransformer"} + ) + assert restored.cut_length == pytest.approx(2.0) + assert restored.skip_length == pytest.approx(1.0) + + def test_from_dict_rejects_other_name(self): + with pytest.raises(ValueError): + PerforationTransformer.from_dict({"name": "Optimize"}) + + +class TestPerforationSpec: + def test_spec_is_gap_mode_tabs(self): + t = PerforationTransformer(cut_length=1.5, skip_length=0.5) + spec = t.to_spec(_workpiece(_unit_square()), None, None) + assert isinstance(spec, TabsSpec) + assert spec.tab_power == 0.0 + + def test_no_workpiece_gives_no_clips(self): + spec = PerforationTransformer().to_spec(None, None, None) + assert spec.clips == [] + + def test_empty_boundaries_give_no_clips(self): + wp = _workpiece(Geometry()) + spec = PerforationTransformer().to_spec(wp, None, None) + assert spec.clips == [] + + def test_square_gets_one_gap_per_period(self): + t = PerforationTransformer(cut_length=1.5, skip_length=0.5) + spec = t.to_spec(_workpiece(_unit_square()), None, None) + assert len(spec.clips) == 40 + assert all(w == pytest.approx(0.5) for _x, _y, w in spec.clips) + + def test_first_gap_follows_a_full_cut(self): + t = PerforationTransformer(cut_length=1.5, skip_length=0.5) + spec = t.to_spec(_workpiece(_unit_square()), None, None) + x, y, _w = spec.clips[0] + assert x == pytest.approx(1.75) + assert y == pytest.approx(0.0) + + def test_clips_are_in_millimetres(self): + t = PerforationTransformer(cut_length=4.0, skip_length=1.0) + wp = _workpiece(_unit_square(), size=(40.0, 10.0)) + spec = t.to_spec(wp, None, None) + xs = [x for x, _y, _w in spec.clips] + ys = [y for _x, y, _w in spec.clips] + assert max(xs) <= 40.0 + 1e-6 + assert max(ys) <= 10.0 + 1e-6 + assert len(spec.clips) == 20 + + def test_gap_never_wraps_past_the_contour_end(self): + t = PerforationTransformer(cut_length=3.0, skip_length=1.0) + spec = t.to_spec(_workpiece(_unit_square()), None, None) + assert len(spec.clips) == 20 + x, y, _w = spec.clips[-1] + assert (x, y) == (pytest.approx(0.0), pytest.approx(0.5)) + + def test_pattern_restarts_for_every_contour(self): + geo = _unit_square(0.0, 0.0, 0.25, 0.25) + geo.extend(_unit_square(0.5, 0.5, 0.75, 0.75)) + t = PerforationTransformer(cut_length=1.5, skip_length=0.5) + spec = t.to_spec(_workpiece(geo, size=(40.0, 40.0)), None, None) + assert len(spec.clips) == 40 + starts = [c for c in spec.clips if c[1] == pytest.approx(20.0)] + assert starts[0][0] == pytest.approx(21.75) + + def test_last_gap_survives_rounding_of_the_contour_length(self): + geo = Geometry() + geo.move_to(0.0, 0.0) + geo.line_to(1.0 - 1e-7, 0.0) + t = PerforationTransformer(cut_length=3.0, skip_length=1.5) + spec = t.to_spec(_workpiece(geo, size=(90.0, 1.0)), None, None) + assert len(spec.clips) == 20 + + def test_contour_shorter_than_a_period_is_left_whole(self): + geo = _unit_square(0.0, 0.0, 0.01, 0.01) + t = PerforationTransformer(cut_length=1.5, skip_length=0.5) + spec = t.to_spec(_workpiece(geo), None, None) + assert spec.clips == [] + + +class TestPerforationOnOps: + def test_cut_and_skip_lengths_on_square(self): + t = PerforationTransformer(cut_length=1.5, skip_length=0.5) + spec = t.to_spec(_workpiece(_unit_square()), None, None) + ops = _square_ops(0.0, 0.0, 20.0, 20.0) + Ops.apply_transformers(ops, [spec]) + ops.preload_state() + assert ops.cut_distance() == pytest.approx(60.0, abs=1e-6) + assert _cut_runs(ops) == 40 diff --git a/rayforge/builtin_addons/rayforge-addon-post/tests/ui_gtk/test_perforation_group.py b/rayforge/builtin_addons/rayforge-addon-post/tests/ui_gtk/test_perforation_group.py new file mode 100644 index 000000000..2006735d9 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-post/tests/ui_gtk/test_perforation_group.py @@ -0,0 +1,56 @@ +# flake8: noqa: E402 +"""UI tests: the Perforation settings group edits cut and skip lengths.""" + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") + +import pytest +from post_processors.transformers import PerforationTransformer +from post_processors.widgets import TRANSFORMER_WIDGETS +from post_processors.widgets.perforation_group import ( + PerforationSettingsGroup, +) + + +class _Page: + use_expanders = True + + +def _build_group(): + transformer = PerforationTransformer(cut_length=1.5, skip_length=0.5) + group = PerforationSettingsGroup("Perforation", transformer, _Page()) + return group, transformer + + +@pytest.mark.ui +def test_perforation_group_is_registered(ui_context): + assert TRANSFORMER_WIDGETS[PerforationTransformer] is ( + PerforationSettingsGroup + ) + + +@pytest.mark.ui +def test_perforation_group_shows_lengths(ui_context): + group, _transformer = _build_group() + assert group.cut_row.get_value_in_base_units() == pytest.approx(1.5) + assert group.skip_row.get_value_in_base_units() == pytest.approx(0.5) + + +@pytest.mark.ui +def test_perforation_group_reports_changes(ui_context): + group, _transformer = _build_group() + sent = [] + + def on_param(sender, key, value, name): + sent.append((key, value)) + + group.param_changed.connect(on_param) + group.cut_row.set_value_in_base_units(3.0) + group._on_cut_changed(group.cut_row) + group.skip_row.set_value_in_base_units(0.25) + group._on_skip_changed(group.skip_row) + + assert ("cut_length", pytest.approx(3.0)) in sent + assert ("skip_length", pytest.approx(0.25)) in sent From 22e916e2c6f35a30a8b1039d7ba00ee211c82568 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Fri, 9 Oct 2026 13:31:17 +0200 Subject: [PATCH 050/104] docs: document the Perforation post-processor Refs #525 --- CHANGELOG.md | 4 ++ website/docs/features/operations/contour.md | 1 + website/docs/features/perforation.md | 43 ++++++++++++++++++ .../current/features/operations/contour.md | 2 + .../current/features/perforation.md | 45 +++++++++++++++++++ .../current/features/operations/contour.md | 2 + .../current/features/perforation.md | 44 ++++++++++++++++++ .../current/features/operations/contour.md | 2 + .../current/features/perforation.md | 45 +++++++++++++++++++ .../current/features/operations/contour.md | 1 + .../current/features/perforation.md | 41 +++++++++++++++++ .../current/features/operations/contour.md | 2 + .../current/features/perforation.md | 43 ++++++++++++++++++ .../current/features/operations/contour.md | 2 + .../current/features/perforation.md | 43 ++++++++++++++++++ .../current/features/operations/contour.md | 1 + .../current/features/perforation.md | 34 ++++++++++++++ website/sidebars.js | 1 + 18 files changed, 356 insertions(+) create mode 100644 website/docs/features/perforation.md create mode 100644 website/i18n/de/docusaurus-plugin-content-docs/current/features/perforation.md create mode 100644 website/i18n/es/docusaurus-plugin-content-docs/current/features/perforation.md create mode 100644 website/i18n/fr/docusaurus-plugin-content-docs/current/features/perforation.md create mode 100644 website/i18n/hi/docusaurus-plugin-content-docs/current/features/perforation.md create mode 100644 website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/perforation.md create mode 100644 website/i18n/uk/docusaurus-plugin-content-docs/current/features/perforation.md create mode 100644 website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/perforation.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 618e33fde..425e740f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Duplicates (duplicate contours inside a workpiece and stacked copies of whole workpieces), and Break Apart (one workpiece per contour, holes and open paths included). Each is a single undo step (#516) +- Contour: Perforation post-processor for dashed cuts. The laser + fires for a Cut Length and is off for a Skip Length along every + contour, starting with a full cut at the start of each contour; off + by default (#525) - Sketcher: boolean operations. Union, Difference, Intersection and Exclude bake the selected closed regions into multi-ring polygons (outer contours counter-clockwise, hole contours clockwise), so diff --git a/website/docs/features/operations/contour.md b/website/docs/features/operations/contour.md index b6615b6fd..d3ed5e0ee 100644 --- a/website/docs/features/operations/contour.md +++ b/website/docs/features/operations/contour.md @@ -208,6 +208,7 @@ See [Kerf](../kerf.md) for detailed guide. Contour operations support several post-processing options: - **[Smooth Path](../smooth.md)** - Reduce jagged edges in cutting paths +- **[Perforation](../perforation.md)** - Cut dashed lines for folds, tear-off lines and stitch holes - **[Holding Tabs](../holding-tabs.md)** - Keep cut pieces attached to stock material - **[Crop to Stock](../crop-to-stock.md)** - Limit cuts to material boundary - **[Path Optimization](../path-optimization.md)** - Reduce travel distance between cuts diff --git a/website/docs/features/perforation.md b/website/docs/features/perforation.md new file mode 100644 index 000000000..4b3c58937 --- /dev/null +++ b/website/docs/features/perforation.md @@ -0,0 +1,43 @@ +--- +description: + "Cut dashed lines in Rayforge with the Perforation post-processor: set a cut length and a skip + length for fold lines, tear-off lines and stitch holes." +--- + +# Perforation + +The **Perforation** post-processor turns a continuous cut into a dashed one. The laser fires for the +**Cut Length**, then travels with the laser off for the **Skip Length**, and repeats this along +every contour of the workpiece. + +## When to Use It + +- Fold lines in card stock and corrugated cardboard +- Tear-off lines (tickets, coupons, packaging) +- Stitch holes in leather +- Dashed lines for decoration + +## Settings + +Perforation is available in the post-processing settings of **Contour** operations. It is off by +default. + +- **Cut Length**: the distance the laser fires before each gap. +- **Skip Length**: the distance travelled with the laser off between two cuts. + +The pattern is measured along each contour of the workpiece and starts again at the start of every +contour, beginning with a full cut. A contour shorter than one cut plus one skip is cut in full. + +## Tips + +- For a fold line that should not cut through, start with a skip length about as long as the cut + length and lower the power. +- Very short lengths (a few tenths of a millimetre) make the laser switch on and off rapidly, which + lowers the effective power. +- Perforation works together with [Holding Tabs](holding-tabs), [Lead-In/Out](lead-in-out) and + [Multi-Pass](multi-pass). + +## Related Pages + +- [Contour Cutting](operations/contour) - The cutting operation that uses perforation +- [Holding Tabs](holding-tabs) - Gaps placed by hand to keep parts attached diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/contour.md b/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/contour.md index 59338b9fd..92a329753 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/contour.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/contour.md @@ -211,6 +211,8 @@ Siehe [Schnittbreite](../kerf.md) für detaillierte Anleitung. Kontur-Operationen unterstützen mehrere Nachbearbeitungsoptionen: - **[Pfad-Glättung](../smooth.md)** - Gezackte Kanten in Schneidepfaden reduzieren +- **[Perforation](../perforation.md)** - Gestrichelte Schnitte für Falzlinien, Abreißlinien und + Nählöcher - **[Halte-Laschen](../holding-tabs.md)** - Geschnittene Teile am Rohmaterial befestigt halten - **[Auf Rohmaterial zuschneiden](../crop-to-stock.md)** - Schnitte auf Materialgrenze beschränken - **[Pfad-Optimierung](../path-optimization.md)** - Verfahrdistanz zwischen Schnitten reduzieren diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/features/perforation.md b/website/i18n/de/docusaurus-plugin-content-docs/current/features/perforation.md new file mode 100644 index 000000000..dbeffde48 --- /dev/null +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/features/perforation.md @@ -0,0 +1,45 @@ +--- +description: + "Gestrichelte Schnitte in Rayforge mit dem Perforation-Nachbearbeitungsschritt: Schnittlänge und + Auslasslänge für Falzlinien, Abreißlinien und Nählöcher festlegen." +--- + +# Perforation + +Der Nachbearbeitungsschritt **Perforation** macht aus einem durchgehenden Schnitt einen +gestrichelten. Der Laser schneidet über die **Schnittlänge**, fährt dann mit ausgeschaltetem Laser +über die **Auslasslänge** und wiederholt das entlang jeder Kontur des Werkstücks. + +## Wann verwenden + +- Falzlinien in Karton und Wellpappe +- Abreißlinien (Tickets, Gutscheine, Verpackungen) +- Nählöcher in Leder +- Dekorative gestrichelte Linien + +## Einstellungen + +Perforation ist in den Nachbearbeitungseinstellungen von **Kontur**-Operationen verfügbar und +standardmäßig ausgeschaltet. + +- **Schnittlänge**: die Strecke, die der Laser vor jeder Lücke schneidet. +- **Auslasslänge**: die Strecke, die zwischen zwei Schnitten mit ausgeschaltetem Laser gefahren + wird. + +Das Muster wird entlang jeder Kontur des Werkstücks gemessen und beginnt am Anfang jeder Kontur neu, +immer mit einem vollen Schnitt. Eine Kontur, die kürzer ist als ein Schnitt plus eine Lücke, wird +vollständig geschnitten. + +## Tipps + +- Für eine Falzlinie, die nicht durchschneiden soll, mit etwa gleich langer Schnitt- und + Auslasslänge beginnen und die Leistung senken. +- Sehr kurze Längen (wenige Zehntelmillimeter) lassen den Laser schnell ein- und ausschalten, was + die effektive Leistung verringert. +- Perforation funktioniert zusammen mit [Halte-Laschen](holding-tabs), [Lead-In/Out](lead-in-out) + und [Mehrfachdurchgang](multi-pass). + +## Verwandte Seiten + +- [Konturschneiden](operations/contour) - Die Schneidoperation, die Perforation verwendet +- [Halte-Laschen](holding-tabs) - Von Hand gesetzte Lücken, die Teile festhalten diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/contour.md b/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/contour.md index b7a160a16..610a88b52 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/contour.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/contour.md @@ -205,6 +205,8 @@ Ver [Kerf](../kerf.md) para una guía detallada. Las operaciones de contorno soportan varias opciones de post-procesamiento: - **[Suavizar Trayectoria](../smooth.md)** - Reduce bordes irregulares en trayectorias de corte +- **[Perforación](../perforation.md)** - Cortes discontinuos para pliegues, líneas de rasgado y + agujeros de costura - **[Pestañas de Sujeción](../holding-tabs.md)** - Mantienen las piezas cortadas adjuntas al material base - **[Recortar al Material](../crop-to-stock.md)** - Limita los cortes al límite del material diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/features/perforation.md b/website/i18n/es/docusaurus-plugin-content-docs/current/features/perforation.md new file mode 100644 index 000000000..42370afd9 --- /dev/null +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/features/perforation.md @@ -0,0 +1,44 @@ +--- +description: + "Corta líneas discontinuas en Rayforge con el posprocesador Perforación: define una longitud de + corte y una longitud de salto para pliegues, líneas de rasgado y agujeros de costura." +--- + +# Perforación + +El posprocesador **Perforación** convierte un corte continuo en uno discontinuo. El láser dispara +durante la **Longitud de corte**, luego se desplaza con el láser apagado durante la **Longitud de +salto**, y repite esto a lo largo de cada contorno de la pieza. + +## Cuándo usarlo + +- Líneas de pliegue en cartulina y cartón ondulado +- Líneas de rasgado (entradas, cupones, embalajes) +- Agujeros de costura en cuero +- Líneas discontinuas decorativas + +## Ajustes + +La perforación está disponible en los ajustes de posprocesado de las operaciones de **Contorno**. +Está desactivada por defecto. + +- **Longitud de corte**: la distancia que dispara el láser antes de cada hueco. +- **Longitud de salto**: la distancia recorrida con el láser apagado entre dos cortes. + +El patrón se mide a lo largo de cada contorno de la pieza y vuelve a empezar al inicio de cada +contorno, siempre con un corte completo. Un contorno más corto que un corte más un salto se corta +entero. + +## Consejos + +- Para una línea de pliegue que no debe atravesar el material, empieza con una longitud de salto + similar a la de corte y baja la potencia. +- Longitudes muy cortas (unas décimas de milímetro) hacen que el láser se encienda y apague + rápidamente, lo que reduce la potencia efectiva. +- La perforación funciona junto con [Pestañas de sujeción](holding-tabs), + [Entrada/Salida](lead-in-out) y [Multipasada](multi-pass). + +## Páginas relacionadas + +- [Corte de contorno](operations/contour) - La operación de corte que usa la perforación +- [Pestañas de sujeción](holding-tabs) - Huecos colocados a mano para mantener las piezas unidas diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/contour.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/contour.md index 768206b5a..d3af80318 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/contour.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/contour.md @@ -210,6 +210,8 @@ Voir [Kerf](../kerf.md) pour un guide détaillé. Les opérations de contour supportent plusieurs options de post-traitement : - **[Lissage de Parcours](../smooth.md)** - Réduire les bords irréguliers dans les parcours de coupe +- **[Perforation](../perforation.md)** - Découpe en pointillés pour plis, lignes prédécoupées et + trous de couture - **[Ponts de Maintien](../holding-tabs.md)** - Maintenir les pièces coupées attachées au matériau de stock - **[Rognage au Stock](../crop-to-stock.md)** - Limiter les coupes à la limite du matériau diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/perforation.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/perforation.md new file mode 100644 index 000000000..2933115d8 --- /dev/null +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/perforation.md @@ -0,0 +1,45 @@ +--- +description: + "Découpez des lignes en pointillés dans Rayforge avec le post-traitement Perforation : réglez une + longueur de coupe et une longueur de saut pour plis, lignes prédécoupées et trous de couture." +--- + +# Perforation + +Le post-traitement **Perforation** transforme une découpe continue en découpe en pointillés. Le +laser tire sur la **Longueur de coupe**, puis se déplace laser éteint sur la **Longueur de saut**, +et répète cela le long de chaque contour de la pièce. + +## Quand l'utiliser + +- Lignes de pliage dans le carton et le carton ondulé +- Lignes prédécoupées (billets, coupons, emballages) +- Trous de couture dans le cuir +- Pointillés décoratifs + +## Réglages + +La perforation est disponible dans les réglages de post-traitement des opérations **Contour**. Elle +est désactivée par défaut. + +- **Longueur de coupe** : la distance sur laquelle le laser tire avant chaque interruption. +- **Longueur de saut** : la distance parcourue laser éteint entre deux coupes. + +Le motif est mesuré le long de chaque contour de la pièce et recommence au début de chaque contour, +toujours par une coupe complète. Un contour plus court qu'une coupe plus un saut est découpé en +entier. + +## Conseils + +- Pour une ligne de pliage qui ne doit pas traverser, commencez avec une longueur de saut proche de + la longueur de coupe et baissez la puissance. +- Des longueurs très courtes (quelques dixièmes de millimètre) font s'allumer et s'éteindre le laser + rapidement, ce qui réduit la puissance effective. +- La perforation fonctionne avec les [Ponts de maintien](holding-tabs), + l'[Entrée/Sortie](lead-in-out) et les [Passes multiples](multi-pass). + +## Pages associées + +- [Découpe de contour](operations/contour) - L'opération de découpe qui utilise la perforation +- [Ponts de maintien](holding-tabs) - Interruptions placées à la main pour garder les pièces + attachées diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/contour.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/contour.md index 6b3031fdc..6875460cd 100644 --- a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/contour.md +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/contour.md @@ -207,6 +207,7 @@ _Step Settings_ टैब का **कॉन्टूर सेटिंग् कॉन्टूर ऑपरेशन कई पोस्ट-प्रोसेसिंग विकल्प समर्थन करते हैं: - **[पाथ चिकना करें](../smooth.md)** - कटिंग पाथ में खुरदरे किनारे घटाएँ +- **[परफोरेशन](../perforation.md)** - मोड़, फाड़ने की लाइन और सिलाई के छेदों के लिए डैश वाली कटिंग - **[होल्डिंग टैब](../holding-tabs.md)** - कटे हुए पार्टों को स्टॉक सामग्री से बंधा रखें - **[स्टॉक पर क्रॉप करें](../crop-to-stock.md)** - कट सामग्री सीमा तक सीमित करें - **[पाथ अनुकूलन](../path-optimization.md)** - कटों के बीच ट्रैवल दूरी घटाएँ diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/perforation.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/perforation.md new file mode 100644 index 000000000..315e37c62 --- /dev/null +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/perforation.md @@ -0,0 +1,41 @@ +--- +description: + "Rayforge में परफोरेशन पोस्ट-प्रोसेसर से डैश वाली लाइनें काटें: मोड़, फाड़ने की लाइन और सिलाई के + छेदों के लिए कट लंबाई और स्किप लंबाई सेट करें।" +--- + +# परफोरेशन + +**परफोरेशन** पोस्ट-प्रोसेसर लगातार कट को डैश वाले कट में बदल देता है। लेज़र **कट लंबाई** तक चलता है, +फिर **स्किप लंबाई** तक लेज़र बंद करके आगे बढ़ता है, और वर्कपीस के हर कंटूर पर यही दोहराता है। + +## कब उपयोग करें + +- कार्ड स्टॉक और नालीदार गत्ते में मोड़ की लाइनें +- फाड़ने की लाइनें (टिकट, कूपन, पैकेजिंग) +- चमड़े में सिलाई के छेद +- सजावटी डैश वाली लाइनें + +## सेटिंग्स + +परफोरेशन **कंटूर** ऑपरेशन की पोस्ट-प्रोसेसिंग सेटिंग्स में उपलब्ध है। यह डिफ़ॉल्ट रूप से बंद रहता +है। + +- **कट लंबाई**: हर गैप से पहले लेज़र जितनी दूरी तक चलता है। +- **स्किप लंबाई**: दो कट के बीच लेज़र बंद करके तय की गई दूरी। + +पैटर्न वर्कपीस के हर कंटूर के साथ मापा जाता है और हर कंटूर की शुरुआत में फिर से शुरू होता है, हमेशा +एक पूरे कट से। एक कट और एक स्किप से छोटा कंटूर पूरा काटा जाता है। + +## सुझाव + +- ऐसी मोड़ की लाइन के लिए जो आर-पार न कटे, कट लंबाई जितनी स्किप लंबाई से शुरू करें और पावर कम करें। +- बहुत छोटी लंबाइयाँ (मिलीमीटर के कुछ दसवें हिस्से) लेज़र को तेज़ी से चालू-बंद करती हैं, जिससे + प्रभावी पावर घटती है। +- परफोरेशन [होल्डिंग टैब](holding-tabs), [लीड-इन/आउट](lead-in-out) और [मल्टी-पास](multi-pass) के साथ + काम करता है। + +## संबंधित पेज + +- [कंटूर कटिंग](operations/contour) - वह कटिंग ऑपरेशन जो परफोरेशन का उपयोग करता है +- [होल्डिंग टैब](holding-tabs) - पार्टों को जोड़े रखने के लिए हाथ से लगाए गए गैप diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/contour.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/contour.md index ce07b0f9a..e8c42538b 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/contour.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/contour.md @@ -207,6 +207,8 @@ Veja [Kerf](../kerf.md) para um guia detalhado. Operações de contorno suportam várias opções de pós-processamento: - **[Suavização de Caminho](../smooth.md)** - Reduz bordas irregulares em caminhos de corte +- **[Perfuração](../perforation.md)** - Cortes tracejados para dobras, linhas destacáveis e furos de + costura - **[Abas de Fixação](../holding-tabs.md)** - Mantém peças cortadas anexadas ao material de estoque - **[Cortar para Estoque](../crop-to-stock.md)** - Limita cortes ao limite do material - **[Otimização de Caminho](../path-optimization.md)** - Reduz distância de deslocamento entre diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/perforation.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/perforation.md new file mode 100644 index 000000000..9a3d40046 --- /dev/null +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/perforation.md @@ -0,0 +1,43 @@ +--- +description: + "Corte linhas tracejadas no Rayforge com o pós-processador Perfuração: defina um comprimento de + corte e um comprimento de salto para dobras, linhas destacáveis e furos de costura." +--- + +# Perfuração + +O pós-processador **Perfuração** transforma um corte contínuo em um corte tracejado. O laser dispara +pelo **Comprimento de corte**, depois se desloca com o laser desligado pelo **Comprimento de +salto**, e repete isso ao longo de cada contorno da peça. + +## Quando usar + +- Linhas de dobra em cartolina e papelão ondulado +- Linhas destacáveis (ingressos, cupons, embalagens) +- Furos de costura em couro +- Linhas tracejadas decorativas + +## Configurações + +A perfuração está disponível nas configurações de pós-processamento das operações de **Contorno**. +Ela vem desativada por padrão. + +- **Comprimento de corte**: a distância em que o laser dispara antes de cada intervalo. +- **Comprimento de salto**: a distância percorrida com o laser desligado entre dois cortes. + +O padrão é medido ao longo de cada contorno da peça e recomeça no início de cada contorno, sempre +com um corte completo. Um contorno menor que um corte mais um salto é cortado inteiro. + +## Dicas + +- Para uma linha de dobra que não deve atravessar o material, comece com um comprimento de salto + parecido com o de corte e reduza a potência. +- Comprimentos muito curtos (alguns décimos de milímetro) fazem o laser ligar e desligar + rapidamente, o que reduz a potência efetiva. +- A perfuração funciona junto com [Abas de fixação](holding-tabs), [Entrada/Saída](lead-in-out) e + [Múltiplas passadas](multi-pass). + +## Páginas relacionadas + +- [Corte de contorno](operations/contour) - A operação de corte que usa a perfuração +- [Abas de fixação](holding-tabs) - Intervalos colocados à mão para manter as peças presas diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/contour.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/contour.md index ed5156b72..5e1803498 100644 --- a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/contour.md +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/contour.md @@ -200,6 +200,8 @@ Операції контуру підтримують кілька опцій постобробки: - **[Згладжування шляху](../smooth.md)** - Зменшення зубчастих країв у шляхах різання +- **[Перфорація](../perforation.md)** - Пунктирне різання для ліній згину, відривних ліній і отворів + для шиття - **[Утримуючі лапки](../holding-tabs.md)** - Утримання вирізаних деталей прикріпленими до матеріалу заготовки - **[Обрізка до заготовки](../crop-to-stock.md)** - Обмеження різів до межі матеріалу diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/perforation.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/perforation.md new file mode 100644 index 000000000..b6d24300e --- /dev/null +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/perforation.md @@ -0,0 +1,43 @@ +--- +description: + "Різання пунктирних ліній у Rayforge за допомогою постобробки Перфорація: задайте довжину різу й + довжину пропуску для ліній згину, відривних ліній і отворів для шиття." +--- + +# Перфорація + +Постобробка **Перфорація** перетворює суцільний різ на пунктирний. Лазер ріже на **Довжині різу**, +потім рухається з вимкненим лазером на **Довжині пропуску** і повторює це вздовж кожного контуру +заготовки. + +## Коли використовувати + +- Лінії згину в картоні та гофрокартоні +- Відривні лінії (квитки, купони, упаковка) +- Отвори для шиття в шкірі +- Декоративні пунктирні лінії + +## Налаштування + +Перфорація доступна в налаштуваннях постобробки операцій **Контур**. За замовчуванням вона вимкнена. + +- **Довжина різу**: відстань, на якій лазер ріже перед кожним проміжком. +- **Довжина пропуску**: відстань, яку лазер проходить вимкненим між двома різами. + +Візерунок вимірюється вздовж кожного контуру заготовки й починається заново на початку кожного +контуру, завжди з повного різу. Контур, коротший за один різ плюс один пропуск, вирізається +повністю. + +## Поради + +- Для лінії згину, яка не повинна прорізати наскрізь, почніть із довжиною пропуску, приблизно рівною + довжині різу, і зменште потужність. +- Дуже малі довжини (кілька десятих міліметра) змушують лазер швидко вмикатися й вимикатися, що + зменшує ефективну потужність. +- Перфорація працює разом з [Утримуючими лапками](holding-tabs), [Входом/виходом](lead-in-out) і + [Багатопрохідним різанням](multi-pass). + +## Пов'язані сторінки + +- [Контурне різання](operations/contour) - Операція різання, що використовує перфорацію +- [Утримуючі лапки](holding-tabs) - Проміжки, розставлені вручну, щоб деталі не випадали diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/contour.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/contour.md index a31f024a4..d7c62834c 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/contour.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/contour.md @@ -192,6 +192,7 @@ 轮廓操作支持多种后处理选项: - **[平滑路径](../smooth.md)** - 减少切割路径中的锯齿状边缘 +- **[穿孔](../perforation.md)** - 用于折线、撕线和缝线孔的虚线切割 - **[固定标签](../holding-tabs.md)** - 保持切割件与材料连接 - **[裁剪到材料](../crop-to-stock.md)** - 将切割限制在材料边界内 - **[路径优化](../path-optimization.md)** - 减少切割之间的行程距离 diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/perforation.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/perforation.md new file mode 100644 index 000000000..156ff706f --- /dev/null +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/perforation.md @@ -0,0 +1,34 @@ +--- +description: +--- + +# 穿孔 + +**穿孔**后处理会把连续切割变成虚线切割。激光在**切割长度**内出光,然后在**跳过长度**内关闭激光移动,并沿工件的每条轮廓重复。 + +## 何时使用 + +- 卡纸和瓦楞纸板的折线 +- 撕线(门票、优惠券、包装) +- 皮革上的缝线孔 +- 装饰性虚线 + +## 设置 + +穿孔位于**轮廓**操作的后处理设置中,默认关闭。 + +- **切割长度**:每个间隙前激光出光的距离。 +- **跳过长度**:两段切割之间关闭激光移动的距离。 + +图案沿工件的每条轮廓测量,并在每条轮廓的起点重新开始,总是以一段完整的切割开始。短于一段切割加一段跳过的轮廓会被完整切割。 + +## 提示 + +- 对于不应切穿的折线,可以先让跳过长度与切割长度大致相同,并降低功率。 +- 非常短的长度(零点几毫米)会让激光快速开关,从而降低有效功率。 +- 穿孔可与[固定标签](holding-tabs)、[引入/引出](lead-in-out)和[多次切割](multi-pass)一起使用。 + +## 相关页面 + +- [轮廓切割](operations/contour) - 使用穿孔的切割操作 +- [固定标签](holding-tabs) - 手动放置以固定零件的间隙 diff --git a/website/sidebars.js b/website/sidebars.js index a293e104e..e2c9793c8 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -78,6 +78,7 @@ module.exports = { 'features/crop-to-stock', 'features/overscan', 'features/holding-tabs', + 'features/perforation', 'features/merge-lines', 'features/lead-in-out', ], From 4ad1f89f03a96fdf2655c7b3bb1c624d04485451 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Sun, 11 Oct 2026 17:26:13 +0200 Subject: [PATCH 051/104] docs(website): fill in the zh-CN description of the Perforation page An empty description makes the Docusaurus build fail with "description" must be a string, breaking the PR Preview step. --- .../current/features/perforation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/perforation.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/perforation.md index 156ff706f..a67f3ad98 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/perforation.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/perforation.md @@ -1,5 +1,5 @@ --- -description: +description: "使用穿孔后处理器在 Rayforge 中切割虚线:为折线、撕线和缝线孔设置切割长度和跳过长度。" --- # 穿孔 From cbe29de479c9ad64c7e4e3ff61ea45f16c9132f3 Mon Sep 17 00:00:00 2001 From: knipknap <149321+knipknap@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:01:18 +0000 Subject: [PATCH 052/104] deps: update pixi dependencies --- debian/requirements-bundle.txt | 2 +- pixi.lock | 2289 ++++++++++++++------------------ pixi.toml | 24 +- requirements.txt | 12 +- 4 files changed, 1048 insertions(+), 1279 deletions(-) diff --git a/debian/requirements-bundle.txt b/debian/requirements-bundle.txt index 3129f9356..8a9002e3c 100644 --- a/debian/requirements-bundle.txt +++ b/debian/requirements-bundle.txt @@ -2,6 +2,6 @@ asyncudp==0.11.0 pyvips==3.2.0 raydriver==0.3.0 raygeo==1.63.0 -ruida-pa==0.21.2 +ruida-pa==0.22.0 trimesh==5.1.1 vtracer==0.6.15 \ No newline at end of file diff --git a/pixi.lock b/pixi.lock index 5c83c311b..a7d6227f0 100644 --- a/pixi.lock +++ b/pixi.lock @@ -27,7 +27,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/at-spi2-atk-2.38.0-h0630a04_3.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/linux-64/at-spi2-core-2.40.3-h0630a04_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/linux-64/atk-1.0-2.38.0-h04ea711_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/binutils_impl_linux-64-2.46.1-default_hfdba357_102.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/binutils_impl_linux-64-2.46.1-default_hfdba357_103.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_10.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/c-ares-1.34.8-hebe6cf0_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/cairo-1.18.6-h3c89d7e_0.conda @@ -40,33 +40,33 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/freeglut-3.2.2-h215f996_4.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/freetype-2.14.3-ha770c72_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/fribidi-1.0.17-h7cc23a3_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/g-ir-build-tools-1.86.0-py312h356d82a_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/g-ir-build-tools-1.86.0-py315h4fefd22_3.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/g-ir-host-tools-1.86.0-h5297791_3.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/gcc_impl_linux-64-16.2.0-h9891192_7.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/gdk-pixbuf-2.44.8-h68053f1_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/gettext-0.25.1-h0e23cf9_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/gettext-tools-0.25.1-h0e23cf9_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/ghostscript-10.08.0-hd2095e1_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/giflib-5.2.2-ha257d8a_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/giflib-6.1.3-hebe6cf0_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/glib-2.90.0-h5e3c387_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/glib-tools-2.90.0-h3d503d9_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/gobject-introspection-1.86.0-py312h1e02d69_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gobject-introspection-1.86.0-py315ha69db51_3.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/graphite2-1.3.15-h54a6638_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/graphviz-14.1.2-h8b86629_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/gtk3-3.24.52-ha5ea40c_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/gtk4-4.24.1-h4848a4c_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/gts-0.7.6-h977cf35_4.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/harfbuzz-14.5.1-ha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/harfbuzz-14.6.0-ha770c72_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/hdf5-1.14.6-nompi_h19486de_110.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/hicolor-icon-theme-0.17-ha770c72_3.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-py310h44b86e0_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/imagemagick-7.1.2_31-agpl_hab780e9_101.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/imagemagick-7.1.2_31-agpl_hde3faa9_102.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/imath-3.2.3-h923cf36_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/jasper-4.2.9-hd038ad9_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/keyutils-1.6.3-h7cc23a3_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/krb5-1.22.2-hbc21106_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/lcms2-2.19.1-h9073bf1_3.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.46.1-default_hbd61a6d_102.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.46.1-default_hbd61a6d_103.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/lerc-4.2.0-hdb68285_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libadwaita-1.10.0-h94ee597_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libaec-1.1.5-h088129d_0.conda @@ -82,11 +82,11 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/libcups-2.3.3-h7a8fb5f_6.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libcurl-8.22.0-ha042cf0_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libdav1d7-1.5.4-hebe6cf0_4.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libde265-1.1.1-h171cf75_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libde265-1.1.3-h4dbf13b_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libdeflate-1.25-hd45a770_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libdicom-1.3.0-hb03c661_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libdrm-2.4.129-h7cc23a3_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20250104-pl5321h373387f_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20260512-pl5321h373387f_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libegl-1.7.0-ha4b6fd6_5.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libegl-devel-1.7.0-ha4b6fd6_5.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libev-4.33-h280c20c_3.conda @@ -112,9 +112,9 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/libglx-devel-1.7.0-ha4b6fd6_5.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-16.2.0-he0feb66_7.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libgraphene-1.10.8-h23b58f8_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libharfbuzz-14.5.1-h23af247_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libharfbuzz-devel-14.5.1-h23af247_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libheif-1.23.5-gpl_h5374add_100.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libharfbuzz-14.6.0-h23af247_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libharfbuzz-devel-14.6.0-h23af247_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libheif-1.23.6-gpl_h5374add_100.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libhwy-1.4.0-h57c4cff_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libiconv-1.18-h0cb94f2_3.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libjpeg-turbo-3.2.0-hb03c661_1.conda @@ -122,19 +122,18 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/liblapack-3.11.0-11_h47877c9_openblas.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libmatio-1.5.30-he0a2e19_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb03c661_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libnghttp2-1.68.1-h74cf4be_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libnsl-2.0.1-hb9d3cd8_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenblas-0.3.34-pthreads_hf13c14d_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libopengl-1.7.0-ha4b6fd6_5.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libopengl-devel-1.7.0-ha4b6fd6_5.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libpciaccess-0.19-hb03c661_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libpng-1.6.59-h922cc85_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libpsl-0.23.1-hd9e3e90_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libpython-3.12.14-h0c77377_3_cpython.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libraw-0.22.2-h074291d_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/librsvg-2.62.4-ha427ee3_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libsanitizer-16.2.0-h3048135_7.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.4-h0737f62_102.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.54.0-h13e7031_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libssh2-1.11.1-h6154650_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-16.2.0-h934c35e_7.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-16.2.0-hdf11a46_7.conda @@ -144,10 +143,9 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/libvulkan-loader-1.4.357.0-h0e34353_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libwebp-base-1.6.0-hd42ef1d_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libxcb-1.17.0-hb83e432_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.38-h280c20c_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libxkbcommon-1.13.2-h51789e4_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-16-2.15.4-hf3af7cc_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.15.4-h7df9aa5_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-16-2.15.4-hf3af7cc_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.15.4-h7df9aa5_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libzip-1.11.4-h6008cf6_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_3.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/lz4-c-1.10.0-hee9eb32_2.conda @@ -156,8 +154,8 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/nspr-4.40-h29cc59b_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/nss-3.118-h445c969_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/numpy-2.5.3-py312he827f4e_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/openexr-3.5.2-h0c14885_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/numpy-2.5.4-py315h11958a5_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openexr-3.5.2-hff7ccaf_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/openjpeg-2.5.4-heb1ab33_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/openjph-0.32.0-h2a08dbc_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/openslide-4.0.1-hbacd67c_0.conda @@ -169,14 +167,13 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/pkg-config-0.29.2-h1114479_1013.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/poppler-26.07.0-he594abd_3.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/pthread-stubs-0.4-h7cc23a3_1004.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/pycairo-1.29.1-py312h33cdcd1_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/pygobject-3.58.0-py312hcb680a2_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/pyopengl-accelerate-3.1.10-py312h0ec8046_3.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.12.14-h5f976f7_3_cpython.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pycairo-1.29.2-py315hce0d9b5_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pygobject-3.58.1-py315h9b33cbc_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pyopengl-accelerate-3.1.10-py315hea8b678_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.15.0-h6e52b48_100_cp315.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/rav1e-0.8.1-h1fbca29_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-hd6e31c0_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/rust-1.98.1-hc89c8c8_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/scipy-1.18.1-py312h106c528_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/rust-1.99.0-h846490e_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/scipy-1.18.1-py315heedce22_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/svt-av1-4.2.0-hd2095e1_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h1df4ec4_4.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/wayland-1.26.0-hc1c935e_2.conda @@ -230,8 +227,8 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/poppler-data-0.4.12-hd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.21.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pyopengl-3.1.10-pyha804496_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.12-9_cp312.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/rust-std-x86_64-unknown-linux-gnu-1.98.1-h2c6d0dc_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.15-9_cp315.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/rust-std-x86_64-unknown-linux-gnu-1.99.0-h480d76f_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-84.0.0-pyh332efcf_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/svgelements-1.9.6-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/sysroot_linux-64-2.28-h4ee821c_9.conda @@ -239,64 +236,64 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - pypi: ./ - pypi: https://files.pythonhosted.org/packages/03/43/344fbf03470b38c11c334fd1ea9f12212036c93f64887d1f31672a95fd8d/ruida_pa-0.22.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/03/c0/a6d8be9c43e4456fb9597fdf8b5e0ce1f0a5df41503acce6d869536e4e23/websockets-17.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz - pypi: https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/d4/1152d1c7ab42d8b908be64fd200ddc870dc9d4925e951198702084aa1a7d/linkify_it_py-2.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/16/67/1f0b582e73ce46511f2799cbdcdbdf9ca98f344f2aad42ff5ff5dde0d8fe/narwhals-2.27.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/17/fe/77f4a24264e728fd95542e53f026a91249b51d1611087b8c82d3a033ea97/asyncudp-0.11.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/18/42/a945f65cc61c739ec80f4112c4b78ed1791f25d33f45f19389c9c9e247e2/pypdf-6.20.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/22/35/285ce45186a60572472c96df5646989ca97710e060419e5c749226a057cf/raygeo-1.63.0.tar.gz - - pypi: https://files.pythonhosted.org/packages/31/c4/e54512d343c4c177e92c45ec16852abe63945b924274c692a5a1867db011/zeroconf-0.151.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/3a/cf/9c2e4d6c179529d5bf4a64cff76fa581486569e9fbdd35bd98f51cb624bf/msgpack-1.2.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/3c/2c/c43c03eaf630435f023f1dc61ec4a4a78951ad5530a62c71cc89bde307b7/pypdf-6.19.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3f/99/2e119d541d596daea39643eb9312b47c7847383951300f889166938035b1/rpyc-6.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/41/51/8041c2e70649e5b7f2a0aedbbbd0609ac099cfaa0cbde2014279c9c05756/cairosvg-2.9.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/4f/a7/aeedb5140afa41fc74c225e9184ab96723a6e873b6ee1c9fede7283456d8/markupsafe-3.0.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - pypi: https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/60/33/df6d7d04854a58619f8349a51e3edb138324130a7562b0bb21f115bb940f/tornado-6.5.10-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/60/45/c7b5c3168458db837e8ceab06dc77824e18202679d0463f0e8f002143a97/tinycss2-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/65/a9/55effa83e64b9ff4f379d9186236d50d03f6d4770d8346805c1b6620c370/fonttools-4.66.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/77/c6/040cbc72480d789a5f40d63fb484d3106554c4dfa2d2b70ad5022057750f/webencodings-0.6.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/80/c9/ddb896767808e3e022453d8dfae26fd52ed404b0aa6fb7f752d39c040208/msgpack-1.2.3-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/82/96/ef49135ce260db4ca4a12b119ed468449cd248db6b1468e2112b546d7a2e/markupsafe-3.0.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/85/11/044d1ae1b4ec0d7af88ee5bc91e081be1022533032b906a7bdabdbb60977/pyproject_hooks-1.3.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/85/9f/83a07b6ec0e043c050cfdd35fb0cf1b7897b91d554d6eea293740309afe7/propcache-0.5.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/8d/15/1633010b26e88e872c93b67c0b6c5e174fb74cb6fb5c1472b4d51d4a8f22/platformdirs-4.13.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8f/f6/fb68b8c08f864ecee8f7f7ee2f1203ed5cba2335215de69e4f621eb47db5/trimesh-5.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/90/11/0e6f11117525ff0eec40ebac3d313376f102df93ca44ad9e893ee85e4f89/pycparser-3.11-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/93/cd/11159bcba680ac5f16e48443206d278768f464cc42fe4bfeb169c33db7b6/raydriver-0.3.0.tar.gz - pypi: https://files.pythonhosted.org/packages/93/d8/ba13451aa6b745c49536e87b6bf8f629b950e84bd0e8308f7dc6883b67e2/cairocffi-1.7.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/9a/8e/cde07147b6fc56b9ae0fe01d9c0bebd730b2be2c3052d3a5a117e13f561e/multidict-7.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9c/1f/19ebc343cc71a7ffa78f17018535adc5cbdd87afb31d7c34874680148b32/ifaddr-0.2.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a0/09/5ecb6f82d35a2f4a4334d0a608fcf764827fa69dccdf5987ce309c16b6c1/ezdxf-1.4.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/a6/6e/babde908b879a3b51ffc230919d06a804912559dd4cf8c94f3fa2af69fec/fonttools-4.66.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - pypi: https://files.pythonhosted.org/packages/a8/71/9219fe65ef32bddc0b5d14e1d98dfefe9d2c9c1b1d1e93efca1f29fcbd4d/xyzservices-2026.9.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ad/60/8674394ce43f4dadae573a1d6f451716438e9eab7d7fe8d643c673a32d85/yarl-1.25.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/ad/9b/9fb3585dabcd73a1b2a6267f63f62649347c9e6d072c9fde365b105abb2c/build-1.6.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/ae/d4/22cbe288c0d5cef7620503be92c0098d82220353fc7e188034a19c517240/websockets-17.2-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b5/55/fdd01894807d20fb6181f13590ae247585b10e909b42e6ada833379fe520/plumbum-2.0.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/bc/7b/ca212cbe170ac8b96e45317ecbcf9c3c3ecf0cdec98d5b088a9c4088929b/yarl-1.25.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/bd/59/6b1daa3b94de8970e2a2787ba73616c2d0675d2f948ef4cad8bef7f21bc6/cssselect2-0.10.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/be/02/bce7a1d70e9d0f6ea8623c16386138c251b5855af6cd3a11433d3fba4e66/semver-3.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c5/0a/1423bb61b5f06376fe6ccf79c8781e2bb5e36c78c1814c78913b7425950f/bokeh-3.10.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/c5/9b/6ce1ead737fe496611bda600c263b9a11ae7bd8f41bb13f9bd7e0a2c37a4/platformdirs-4.12.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c7/06/dace3e27af26690cb20bead80dbac42941b0841eb689b8aabbd67dde16f0/pymupdf-1.28.2-cp310-abi3-manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/c7/f0/1d8615d4ac47002b75072b0f4cb2101a7608ee9f6f94f1bd62717c1d7452/zeroconf-0.151.5-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/ce/4e/58b9b1460afc97a4c0b17ee89af701c4011d4d7f46470eba3aaff76a8069/propcache-0.5.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/cf/4c/c73f828fdbcd37eaf21d08fa852544a3ca7c2dbb3ea76873d64f2ea413d1/opencv_python-5.0.0.93-cp37-abi3-manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/cf/f5/2fcc5e30053a938286f17d0edf3f0850b8061b984256fa7c26850b9c8809/aiohttp-3.14.5-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/d1/89/5d4c86da1130d9059681e5b6cd7645df5c10279a6a079c5c37dcb2cc6f3f/narwhals-2.27.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/df/f3/90993aab504fa2e1f28fcc09aa16b6ea4f00e75a037d9136e737855833e2/pyvips-3.2.0.tar.gz - - pypi: https://files.pythonhosted.org/packages/e7/29/2094fd7719146ca7bb236be9751b5929d431f643cbab669aed89cf121883/ezdxf-1.4.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/ed/5b/db08419c1e1f7c9d60cfd2787b2b517d7ae4ebbda8281b48a33eb5141467/multidict-6.9.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/e4/62/c3c23511ebe3d76dc679db0bb045dfd20ed3452bec12dcbbe8d655498a7d/vtracer-0.6.15.tar.gz + - pypi: https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/fa/48/f36d233ce749d1bb880cb5e6f8bb171dc2effabc7435c31da604d831b3ec/gitpython-3.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/ff/72/b12534f2d335448bc98504610c8d57520e530dab840251871a72cbb6c227/vtracer-0.6.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl osx-arm64: - conda: https://conda.anaconda.org/conda-forge/noarch/adwaita-icon-theme-51.0-unix_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-hbd8a1cb_0.conda @@ -311,8 +308,8 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/poppler-data-0.4.12-hd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.21.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pyopengl-3.1.10-pyh534df25_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-9_cp314.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/rust-std-aarch64-apple-darwin-1.98.1-hf6ec828_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.15-9_cp315.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/rust-std-aarch64-apple-darwin-1.99.0-h451e77e_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-84.0.0-pyh332efcf_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/svgelements-1.9.6-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda @@ -322,7 +319,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/atk-1.0-2.38.0-hd03087b_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/aws-c-auth-1.0.0-h7ad4001_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/aws-c-cal-1.0.0-h50d513f_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/aws-c-common-1.0.2-h74c22ad_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/aws-c-common-1.0.3-h74c22ad_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/aws-c-compression-1.0.0-h50d513f_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/aws-c-http-1.0.0-h7b2e6b5_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/aws-c-io-1.0.0-h49e100f_0.conda @@ -339,26 +336,26 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/fontconfig-2.18.3-h81aa574_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/freetype-2.14.3-hce30654_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/fribidi-1.0.17-hb001647_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/g-ir-build-tools-1.86.0-py314h0924591_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/g-ir-build-tools-1.86.0-py315h01a7eab_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/g-ir-host-tools-1.86.0-hab4cfce_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/gdk-pixbuf-2.44.8-h5867e2c_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/gettext-0.25.1-hae62183_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/gettext-tools-0.25.1-hae62183_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ghostscript-10.04.0-hf9b8971_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/giflib-5.2.2-hd20048c_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/giflib-6.1.3-h74c22ad_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/glib-2.90.0-h37000cc_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/glib-tools-2.90.0-h06d1d6c_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/gobject-introspection-1.86.0-py314h9e5fe9d_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/gobject-introspection-1.86.0-py315h6882d7b_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/graphite2-1.3.15-h784d473_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/graphviz-14.1.2-hec8c438_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/gtk3-3.24.52-hc0f3e19_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/gtk4-4.24.1-h7a6b1a4_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/gts-0.7.6-he42f4ea_4.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/harfbuzz-14.5.1-hce30654_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/harfbuzz-14.6.0-hce30654_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/hdf5-2.2.0-nompi_h9376f17_101.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/hicolor-icon-theme-0.17-hce30654_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-78.3-py310h579977c_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/imagemagick-7.1.2_31-agpl_h07d2cc2_101.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/imagemagick-7.1.2_31-agpl_hca089ec_102.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/imath-3.2.3-h83d81d2_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/jasper-4.2.9-hc111fee_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/krb5-1.22.2-h34f8a20_2.conda @@ -376,12 +373,12 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlienc-1.2.0-h2ddc9cb_4.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcblas-3.11.0-11_hb0561ab_openblas.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcurl-8.22.0-h6651222_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-23.1.2-h55c6f16_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-23.1.3-h55c6f16_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libdav1d7-1.5.4-h74c22ad_4.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libde265-1.1.1-h3feff0a_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libde265-1.1.3-hfbe1efa_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libdeflate-1.25-he7e0567_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libdicom-1.3.0-h84a0fba_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libedit-3.1.20250104-pl5321h26f1114_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libedit-3.1.20260512-pl5321h26f1114_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libev-4.33-h1a92334_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexif-0.6.26-h1a92334_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.8.5-h6111c0a_0.conda @@ -397,9 +394,9 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgirepository-1.86.0-hccdf2ab_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libglib-2.90.0-hccd281b_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgraphene-1.10.8-h77cb426_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libharfbuzz-14.5.1-hcda0f7c_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libharfbuzz-devel-14.5.1-hcda0f7c_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libheif-1.23.5-gpl_hea44f65_100.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libharfbuzz-14.6.0-hcda0f7c_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libharfbuzz-devel-14.6.0-hcda0f7c_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libheif-1.23.6-gpl_hea44f65_100.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libhwy-1.4.0-hd2bdd19_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libiconv-1.18-he4c29f2_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libintl-0.25.1-hae62183_2.conda @@ -414,29 +411,28 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenblas-0.3.34-openmp_h5e6e99c_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libpng-1.6.59-hf5e6511_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libpsl-0.23.1-hdb0c161_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libpython-3.14.7-h4311e70_106_cp314.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libraw-0.22.2-h2d05ff4_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/librsvg-2.62.4-hc8ac265_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.53.4-h34a968b_102.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.54.0-hca69786_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libssh2-1.11.1-hbf2880b_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libtiff-4.7.2-hf67920b_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libvips-8.18.7-h588ea66_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libvulkan-loader-1.4.357.0-hfbe1efa_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libwebp-base-1.6.0-h202fb40_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxcb-1.17.0-hbffa61f_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-16-2.15.4-heb56d2d_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-2.15.4-h550178d_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-16-2.15.4-heb56d2d_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-2.15.4-h550178d_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzip-1.11.4-hcd2b5ac_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_3.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvm-openmp-23.1.2-hdb3d66b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvm-openmp-23.1.3-hdb3d66b_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/lz4-c-1.10.0-hdca3b7d_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/lzo-2.10-h74c22ad_1003.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ministream-0.99.1-hab76ae8_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.6-he64c551_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/nspr-4.40-hdcbdcf5_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/nss-3.118-h1c710a3_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/numpy-2.5.3-py314he06036b_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openexr-3.5.2-hd7663ec_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/numpy-2.5.4-py315h39cfc7d_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openexr-3.5.2-h1458d73_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openjpeg-2.5.4-h4d1e80c_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openjph-0.32.0-h16ef464_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openslide-4.0.1-h8cefcf1_0.conda @@ -448,15 +444,14 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pkg-config-0.29.2-he0b766d_1013.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/poppler-26.07.0-h4cfec15_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pthread-stubs-0.4-hb001647_1004.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pycairo-1.29.1-py314h042460f_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pygobject-3.58.0-py314h098b748_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyopengl-accelerate-3.1.10-py314hdd70a12_3.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.14.7-h0ae1c2c_106_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pycairo-1.29.2-py315ha70f204_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pygobject-3.58.1-py315ha5599b7_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyopengl-accelerate-3.1.10-py315he30f02c_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.15.0-h2e4dbab_100_cp315.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/rav1e-0.8.1-h8246384_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h8b90a29_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/rust-1.98.1-h4ff7c5d_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/rust-1.99.0-haf00ee1_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/s2n-1.7.7-h430f396_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/scipy-1.18.1-py314hd9f3d4e_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/scipy-1.18.1-py315he7dc495_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/svt-av1-4.2.0-h484c67d_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-hbeba79b_4.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/x265-3.5-h55d2f36_4.conda @@ -472,59 +467,59 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hf451053_7.conda - pypi: ./ - pypi: https://files.pythonhosted.org/packages/03/43/344fbf03470b38c11c334fd1ea9f12212036c93f64887d1f31672a95fd8d/ruida_pa-0.22.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz - pypi: https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/12/83/c745b210ec49379ebfe627e166b527f44671a1f6ec5e1e219d91caa8964d/fonttools-4.66.1-cp314-cp314-macosx_10_15_universal2.whl + - pypi: https://files.pythonhosted.org/packages/12/ee/2fba0aecb52e7020e189f684148783aa0b9cfa3b3bfb0b400646eef70ad4/yarl-1.25.1-cp315-cp315-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/13/d4/1152d1c7ab42d8b908be64fd200ddc870dc9d4925e951198702084aa1a7d/linkify_it_py-2.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/16/67/1f0b582e73ce46511f2799cbdcdbdf9ca98f344f2aad42ff5ff5dde0d8fe/narwhals-2.27.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/15/56/50cf2a45c6163edafd737e2fd555103a26ce6748e1e241fb56ed445ea835/msgpack-1.2.3-cp315-cp315-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/17/fe/77f4a24264e728fd95542e53f026a91249b51d1611087b8c82d3a033ea97/asyncudp-0.11.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/18/42/a945f65cc61c739ec80f4112c4b78ed1791f25d33f45f19389c9c9e247e2/pypdf-6.20.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/29/5c/969a1b72692055fd2a419590c41847ec9144fefb97b75ff1cde5b6372891/aiohttp-3.14.5-cp315-cp315-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/3c/2c/c43c03eaf630435f023f1dc61ec4a4a78951ad5530a62c71cc89bde307b7/pypdf-6.19.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3f/99/2e119d541d596daea39643eb9312b47c7847383951300f889166938035b1/rpyc-6.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/41/51/8041c2e70649e5b7f2a0aedbbbd0609ac099cfaa0cbde2014279c9c05756/cairosvg-2.9.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/5c/09/1abf942723c0001d9c2fca1551907dade6304517b982b0bf10bba107fa81/websockets-17.2-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/60/45/c7b5c3168458db837e8ceab06dc77824e18202679d0463f0e8f002143a97/tinycss2-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/6e/bb/dda344765ffd3430afe1a1c66c866a57fae67786537d4f14607df6505ac1/yarl-1.25.1-cp314-cp314-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/66/06/bc7b944f81514378b2c2ab96c17df19e871cd33b9be0f1f6dfc975457e5e/websockets-17.2-cp315-cp315-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/72/e2/36f043cdf3970bc9f07cbd0af0263e40681d5a4693e9bb250b29aa425fae/zeroconf-0.151.5-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/77/c6/040cbc72480d789a5f40d63fb484d3106554c4dfa2d2b70ad5022057750f/webencodings-0.6.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/85/11/044d1ae1b4ec0d7af88ee5bc91e081be1022533032b906a7bdabdbb60977/pyproject_hooks-1.3.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/8d/15/1633010b26e88e872c93b67c0b6c5e174fb74cb6fb5c1472b4d51d4a8f22/platformdirs-4.13.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8f/f6/fb68b8c08f864ecee8f7f7ee2f1203ed5cba2335215de69e4f621eb47db5/trimesh-5.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/90/11/0e6f11117525ff0eec40ebac3d313376f102df93ca44ad9e893ee85e4f89/pycparser-3.11-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/93/d8/ba13451aa6b745c49536e87b6bf8f629b950e84bd0e8308f7dc6883b67e2/cairocffi-1.7.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/96/24/3ec292b44064c16229e064d770b2625bd8ea941aa61f44905a9fa44942c0/markupsafe-3.0.4-cp315-cp315-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9c/1f/19ebc343cc71a7ffa78f17018535adc5cbdd87afb31d7c34874680148b32/ifaddr-0.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9c/75/76f6ade78f6102c61034f828e2a22616708df2c9504bc8d6af9dd8f73dc5/opencv_python-5.0.0.93-cp37-abi3-macosx_13_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/a0/09/5ecb6f82d35a2f4a4334d0a608fcf764827fa69dccdf5987ce309c16b6c1/ezdxf-1.4.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/a0/71/551608543ee5d590f7e8d522267665d6d9946866ad2a2a70a770f7c70793/msgpack-1.2.3-cp314-cp314-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/a5/1a/ad561f99f90884089e6403b76c220610809429ba868a81a2e7ce115d32e0/propcache-0.5.4-cp315-cp315-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a8/67/7094dfc74bd9b7263a71a1a5706965750600d7cf740795ce123860986206/zeroconf-0.151.5-cp315-cp315-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/a8/71/9219fe65ef32bddc0b5d14e1d98dfefe9d2c9c1b1d1e93efca1f29fcbd4d/xyzservices-2026.9.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/ad/40/e2d117b048d47282ade906fbfd92814cbee5647afc13fda88a3406039372/markupsafe-3.0.4-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/ad/9b/9fb3585dabcd73a1b2a6267f63f62649347c9e6d072c9fde365b105abb2c/build-1.6.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/b5/55/fdd01894807d20fb6181f13590ae247585b10e909b42e6ada833379fe520/plumbum-2.0.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b6/83/cf690ab80a0db0f50b300a7141f45dbf1bbbb6b32457f74bb55f47d38f5c/multidict-7.1.0-cp315-cp315-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/bd/59/6b1daa3b94de8970e2a2787ba73616c2d0675d2f948ef4cad8bef7f21bc6/cssselect2-0.10.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/be/02/bce7a1d70e9d0f6ea8623c16386138c251b5855af6cd3a11433d3fba4e66/semver-3.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c5/0a/1423bb61b5f06376fe6ccf79c8781e2bb5e36c78c1814c78913b7425950f/bokeh-3.10.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/c5/9b/6ce1ead737fe496611bda600c263b9a11ae7bd8f41bb13f9bd7e0a2c37a4/platformdirs-4.12.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/c8/c3/0bebaaec98aa7cb43e04a8a6079b4aecb83338f6b4b3a32eb837c4c484ea/raydriver-0.3.0-cp311-abi3-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/ca/c6/22c009b147de23fb8d1b18587ec303ab99fe4af423802185a2991a0a5bc4/vtracer-0.6.15-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/cd/5b/ff5fc58fa2427c30dea74c90053f4fc5eda1e7f3833ed3ecc7147fe2b311/tornado-6.5.10-cp39-abi3-macosx_10_9_universal2.whl + - pypi: https://files.pythonhosted.org/packages/d1/89/5d4c86da1130d9059681e5b6cd7645df5c10279a6a079c5c37dcb2cc6f3f/narwhals-2.27.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/df/f3/90993aab504fa2e1f28fcc09aa16b6ea4f00e75a037d9136e737855833e2/pyvips-3.2.0.tar.gz - - pypi: https://files.pythonhosted.org/packages/e2/22/b533b493d7025456f44518b33e53e000021a20fe7c27b88cf3d341df7186/propcache-0.5.4-cp314-cp314-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/f9/b4/8e6d950f02ca6ecc00ecf671f2ddb5dab7017671a8d197326f75621f5786/multidict-6.9.1-cp314-cp314-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/e4/62/c3c23511ebe3d76dc679db0bb045dfd20ed3452bec12dcbbe8d655498a7d/vtracer-0.6.15.tar.gz + - pypi: https://files.pythonhosted.org/packages/eb/95/235679d5fe4265c251418cd02321de069281a700415389e14c4cce442e3d/fonttools-4.66.1-cp315-cp315-macosx_10_15_universal2.whl - pypi: https://files.pythonhosted.org/packages/fa/01/3591f781b417b382a8487a2356e927acfe858b1043bab0ec47f6805bb109/pymupdf-1.28.2-cp310-abi3-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/fa/48/f36d233ce749d1bb880cb5e6f8bb171dc2effabc7435c31da604d831b3ec/gitpython-3.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl @@ -561,7 +556,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/libcups-2.3.3-h7a8fb5f_6.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libdeflate-1.25-hd45a770_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libdrm-2.4.129-h7cc23a3_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20250104-pl5321h373387f_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20260512-pl5321h373387f_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libegl-1.7.0-ha4b6fd6_5.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libegl-devel-1.7.0-ha4b6fd6_5.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.5-hd2095e1_0.conda @@ -579,7 +574,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/libglx-devel-1.7.0-ha4b6fd6_5.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-16.2.0-he0feb66_7.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libgraphene-1.10.8-h23b58f8_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libharfbuzz-14.5.1-h23af247_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libharfbuzz-14.6.0-h23af247_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libiconv-1.18-h0cb94f2_3.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libjpeg-turbo-3.2.0-hb03c661_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_1.conda @@ -593,8 +588,8 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/libwebp-base-1.6.0-hd42ef1d_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libxcb-1.17.0-hb83e432_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libxkbcommon-1.13.2-h51789e4_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-16-2.15.4-hf3af7cc_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.15.4-h7df9aa5_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-16-2.15.4-hf3af7cc_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.15.4-h7df9aa5_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_3.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/ministream-0.99.1-hca128c4_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_1.conda @@ -654,7 +649,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libadwaita-1.10.0-h27f3ac1_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libasprintf-0.25.1-hae62183_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libasprintf-devel-0.25.1-hae62183_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-23.1.2-h55c6f16_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-23.1.3-h55c6f16_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libdeflate-1.25-he7e0567_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.8.5-h6111c0a_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.7.0-h47dc5ef_1.conda @@ -664,7 +659,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgettextpo-devel-0.25.1-hae62183_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libglib-2.90.0-hccd281b_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgraphene-1.10.8-h77cb426_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libharfbuzz-14.5.1-hcda0f7c_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libharfbuzz-14.6.0-hcda0f7c_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libiconv-1.18-he4c29f2_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libintl-0.25.1-hae62183_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libintl-devel-0.25.1-hae62183_2.conda @@ -675,7 +670,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libtiff-4.7.2-hf67920b_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libvulkan-loader-1.4.357.0-hfbe1efa_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libwebp-base-1.6.0-h202fb40_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-16-2.15.4-heb56d2d_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-16-2.15.4-heb56d2d_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ministream-0.99.1-hab76ae8_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pango-1.58.2-hf9b25ef_1.conda @@ -698,7 +693,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/at-spi2-atk-2.38.0-h0630a04_3.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/linux-64/at-spi2-core-2.40.3-h0630a04_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/linux-64/atk-1.0-2.38.0-h04ea711_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/binutils_impl_linux-64-2.46.1-default_hfdba357_102.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/binutils_impl_linux-64-2.46.1-default_hfdba357_103.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_10.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/c-ares-1.34.8-hebe6cf0_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/cairo-1.18.6-h3c89d7e_0.conda @@ -711,33 +706,33 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/freeglut-3.2.2-h215f996_4.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/freetype-2.14.3-ha770c72_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/fribidi-1.0.17-h7cc23a3_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/g-ir-build-tools-1.86.0-py312h356d82a_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/g-ir-build-tools-1.86.0-py315h4fefd22_3.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/g-ir-host-tools-1.86.0-h5297791_3.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/gcc_impl_linux-64-16.2.0-h9891192_7.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/gdk-pixbuf-2.44.8-h68053f1_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/gettext-0.25.1-h0e23cf9_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/gettext-tools-0.25.1-h0e23cf9_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/ghostscript-10.08.0-hd2095e1_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/giflib-5.2.2-ha257d8a_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/giflib-6.1.3-hebe6cf0_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/glib-2.90.0-h5e3c387_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/glib-tools-2.90.0-h3d503d9_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/gobject-introspection-1.86.0-py312h1e02d69_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gobject-introspection-1.86.0-py315ha69db51_3.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/graphite2-1.3.15-h54a6638_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/graphviz-14.1.2-h8b86629_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/gtk3-3.24.52-ha5ea40c_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/gtk4-4.24.1-h4848a4c_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/gts-0.7.6-h977cf35_4.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/harfbuzz-14.5.1-ha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/harfbuzz-14.6.0-ha770c72_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/hdf5-1.14.6-nompi_h19486de_110.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/hicolor-icon-theme-0.17-ha770c72_3.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-py310h44b86e0_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/imagemagick-7.1.2_31-agpl_hab780e9_101.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/imagemagick-7.1.2_31-agpl_hde3faa9_102.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/imath-3.2.3-h923cf36_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/jasper-4.2.9-hd038ad9_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/keyutils-1.6.3-h7cc23a3_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/krb5-1.22.2-hbc21106_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/lcms2-2.19.1-h9073bf1_3.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.46.1-default_hbd61a6d_102.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.46.1-default_hbd61a6d_103.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/lerc-4.2.0-hdb68285_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libadwaita-1.10.0-h94ee597_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libaec-1.1.5-h088129d_0.conda @@ -753,11 +748,11 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/libcups-2.3.3-h7a8fb5f_6.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libcurl-8.22.0-ha042cf0_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libdav1d7-1.5.4-hebe6cf0_4.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libde265-1.1.1-h171cf75_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libde265-1.1.3-h4dbf13b_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libdeflate-1.25-hd45a770_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libdicom-1.3.0-hb03c661_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libdrm-2.4.129-h7cc23a3_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20250104-pl5321h373387f_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20260512-pl5321h373387f_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libegl-1.7.0-ha4b6fd6_5.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libegl-devel-1.7.0-ha4b6fd6_5.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libev-4.33-h280c20c_3.conda @@ -784,9 +779,9 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/libglx-devel-1.7.0-ha4b6fd6_5.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-16.2.0-he0feb66_7.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libgraphene-1.10.8-h23b58f8_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libharfbuzz-14.5.1-h23af247_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libharfbuzz-devel-14.5.1-h23af247_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libheif-1.23.5-gpl_h5374add_100.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libharfbuzz-14.6.0-h23af247_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libharfbuzz-devel-14.6.0-h23af247_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libheif-1.23.6-gpl_h5374add_100.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libhwy-1.4.0-h57c4cff_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libiconv-1.18-h0cb94f2_3.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libjpeg-turbo-3.2.0-hb03c661_1.conda @@ -794,19 +789,18 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/liblapack-3.11.0-11_h47877c9_openblas.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libmatio-1.5.30-he0a2e19_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb03c661_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libnghttp2-1.68.1-h74cf4be_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libnsl-2.0.1-hb9d3cd8_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenblas-0.3.34-pthreads_hf13c14d_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libopengl-1.7.0-ha4b6fd6_5.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libopengl-devel-1.7.0-ha4b6fd6_5.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libpciaccess-0.19-hb03c661_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libpng-1.6.59-h922cc85_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libpsl-0.23.1-hd9e3e90_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libpython-3.12.14-h0c77377_3_cpython.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libraw-0.22.2-h074291d_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/librsvg-2.62.4-ha427ee3_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libsanitizer-16.2.0-h3048135_7.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.4-h0737f62_102.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.54.0-h13e7031_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libssh2-1.11.1-h6154650_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-16.2.0-h934c35e_7.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-16.2.0-hdf11a46_7.conda @@ -816,10 +810,9 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/libvulkan-loader-1.4.357.0-h0e34353_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libwebp-base-1.6.0-hd42ef1d_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libxcb-1.17.0-hb83e432_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.38-h280c20c_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libxkbcommon-1.13.2-h51789e4_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-16-2.15.4-hf3af7cc_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.15.4-h7df9aa5_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-16-2.15.4-hf3af7cc_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.15.4-h7df9aa5_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libxmlb-0.3.29-he944c4b_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libzip-1.11.4-h6008cf6_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_3.conda @@ -829,8 +822,8 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/nspr-4.40-h29cc59b_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/nss-3.118-h445c969_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/numpy-2.5.3-py312he827f4e_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/openexr-3.5.2-h0c14885_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/numpy-2.5.4-py315h11958a5_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openexr-3.5.2-hff7ccaf_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/openjpeg-2.5.4-heb1ab33_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/openjph-0.32.0-h2a08dbc_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/openslide-4.0.1-hbacd67c_0.conda @@ -842,14 +835,13 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/pkg-config-0.29.2-h1114479_1013.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/poppler-26.07.0-he594abd_3.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/pthread-stubs-0.4-h7cc23a3_1004.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/pycairo-1.29.1-py312h33cdcd1_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/pygobject-3.58.0-py312hcb680a2_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/pyopengl-accelerate-3.1.10-py312h0ec8046_3.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.12.14-h5f976f7_3_cpython.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pycairo-1.29.2-py315hce0d9b5_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pygobject-3.58.1-py315h9b33cbc_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pyopengl-accelerate-3.1.10-py315hea8b678_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.15.0-h6e52b48_100_cp315.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/rav1e-0.8.1-h1fbca29_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-hd6e31c0_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/rust-1.98.1-hc89c8c8_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/scipy-1.18.1-py312h106c528_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/rust-1.99.0-h846490e_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/scipy-1.18.1-py315heedce22_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/svt-av1-4.2.0-hd2095e1_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h1df4ec4_4.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/wayland-1.26.0-hc1c935e_2.conda @@ -897,7 +889,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-ubuntu-0.83-h77eed37_3.conda - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-ecosystem-1-0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-forge-1-hc364b38_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/kernel-headers_linux-64-4.18.0-he073ed8_9.conda - conda: https://conda.anaconda.org/conda-forge/noarch/libgcc-devel_linux-64-16.2.0-he3ce08f_107.conda - conda: https://conda.anaconda.org/conda-forge/noarch/libstdcxx-devel_linux-64-16.2.0-h86e191b_107.conda @@ -907,93 +899,93 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.21.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pyopengl-3.1.10-pyha804496_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-9.1.1-pyhc364b38_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.12-9_cp312.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/rust-std-x86_64-unknown-linux-gnu-1.98.1-h2c6d0dc_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.15-9_cp315.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/rust-std-x86_64-unknown-linux-gnu-1.99.0-h480d76f_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-84.0.0-pyh332efcf_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/svgelements-1.9.6-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/sysroot_linux-64-2.28-h4ee821c_9.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.5.0-pyh5ded981_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - pypi: ./ - pypi: https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/03/43/344fbf03470b38c11c334fd1ea9f12212036c93f64887d1f31672a95fd8d/ruida_pa-0.22.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/03/c0/a6d8be9c43e4456fb9597fdf8b5e0ce1f0a5df41503acce6d869536e4e23/websockets-17.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz - pypi: https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/d4/1152d1c7ab42d8b908be64fd200ddc870dc9d4925e951198702084aa1a7d/linkify_it_py-2.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/16/67/1f0b582e73ce46511f2799cbdcdbdf9ca98f344f2aad42ff5ff5dde0d8fe/narwhals-2.27.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/16/7d/e9ffbadfbf89c93848412d04594135c4ae8c1d37d9e053b9c3ed718fabc4/python_discovery-1.6.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/17/fe/77f4a24264e728fd95542e53f026a91249b51d1611087b8c82d3a033ea97/asyncudp-0.11.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/18/42/a945f65cc61c739ec80f4112c4b78ed1791f25d33f45f19389c9c9e247e2/pypdf-6.20.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/22/35/285ce45186a60572472c96df5646989ca97710e060419e5c749226a057cf/raygeo-1.63.0.tar.gz - pypi: https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/31/c4/e54512d343c4c177e92c45ec16852abe63945b924274c692a5a1867db011/zeroconf-0.151.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/2c/3c/4a01195d93420cad1175bedad13a515dc8a56f95a6e39789b92e582682f5/ruff-0.17.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/3a/cf/9c2e4d6c179529d5bf4a64cff76fa581486569e9fbdd35bd98f51cb624bf/msgpack-1.2.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/3c/2c/c43c03eaf630435f023f1dc61ec4a4a78951ad5530a62c71cc89bde307b7/pypdf-6.19.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3f/99/2e119d541d596daea39643eb9312b47c7847383951300f889166938035b1/rpyc-6.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/41/51/8041c2e70649e5b7f2a0aedbbbd0609ac099cfaa0cbde2014279c9c05756/cairosvg-2.9.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/44/b0/554d720d71083ccd24ba2f376c048544c073570e7bb18b34d769cef77f66/pyflakes-4.0.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/45/e2/bbb7129c9e7999a6b8ee9cca3b66486c25c423ab5a75f34071798b74ce94/pre_commit-4.6.2-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/4a/4d/2983a9fc0a412a09151299d1ec9eb7511e3d577f7213e19395fabebf9db6/filelock-4.0.11-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/4f/a7/aeedb5140afa41fc74c225e9184ab96723a6e873b6ee1c9fede7283456d8/markupsafe-3.0.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/52/5b/799c3e5b8e04d5fa1c9b61a4c1f5079603e747205c7311b92abd30283df5/virtualenv-21.14.7-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/54/c8/12811c9b48fde162bb72b6f2e78fada9a247a09d7bb5be2050a5d099c77b/nodeenv-1.11.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/56/e2/480bcdb153feecde4d736b18c908c7eee4846f11d7547d2e0c71499af630/pycodestyle-2.15.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/60/33/df6d7d04854a58619f8349a51e3edb138324130a7562b0bb21f115bb940f/tornado-6.5.10-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/60/45/c7b5c3168458db837e8ceab06dc77824e18202679d0463f0e8f002143a97/tinycss2-1.5.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/61/3b/50ed32583bfffa737df67d33b9c15d7b25df232c55ab606d059f610ef6ca/python_discovery-1.6.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/65/a9/55effa83e64b9ff4f379d9186236d50d03f6d4770d8346805c1b6620c370/fonttools-4.66.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/77/c6/040cbc72480d789a5f40d63fb484d3106554c4dfa2d2b70ad5022057750f/webencodings-0.6.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/80/c9/ddb896767808e3e022453d8dfae26fd52ed404b0aa6fb7f752d39c040208/msgpack-1.2.3-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/82/96/ef49135ce260db4ca4a12b119ed468449cd248db6b1468e2112b546d7a2e/markupsafe-3.0.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/85/11/044d1ae1b4ec0d7af88ee5bc91e081be1022533032b906a7bdabdbb60977/pyproject_hooks-1.3.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/85/9f/83a07b6ec0e043c050cfdd35fb0cf1b7897b91d554d6eea293740309afe7/propcache-0.5.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/8c/be/bf49399ad2e788121f595903a56447d4b778d67acbb01ecfc1c6d5cf91f6/pygobject_stubs-2.17.0.tar.gz + - pypi: https://files.pythonhosted.org/packages/8d/15/1633010b26e88e872c93b67c0b6c5e174fb74cb6fb5c1472b4d51d4a8f22/platformdirs-4.13.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8f/f6/fb68b8c08f864ecee8f7f7ee2f1203ed5cba2335215de69e4f621eb47db5/trimesh-5.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/90/11/0e6f11117525ff0eec40ebac3d313376f102df93ca44ad9e893ee85e4f89/pycparser-3.11-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/93/cd/11159bcba680ac5f16e48443206d278768f464cc42fe4bfeb169c33db7b6/raydriver-0.3.0.tar.gz - pypi: https://files.pythonhosted.org/packages/93/d8/ba13451aa6b745c49536e87b6bf8f629b950e84bd0e8308f7dc6883b67e2/cairocffi-1.7.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/9a/8e/cde07147b6fc56b9ae0fe01d9c0bebd730b2be2c3052d3a5a117e13f561e/multidict-7.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9c/1f/19ebc343cc71a7ffa78f17018535adc5cbdd87afb31d7c34874680148b32/ifaddr-0.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a0/09/5ecb6f82d35a2f4a4334d0a608fcf764827fa69dccdf5987ce309c16b6c1/ezdxf-1.4.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/a6/6e/babde908b879a3b51ffc230919d06a804912559dd4cf8c94f3fa2af69fec/fonttools-4.66.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - pypi: https://files.pythonhosted.org/packages/a8/71/9219fe65ef32bddc0b5d14e1d98dfefe9d2c9c1b1d1e93efca1f29fcbd4d/xyzservices-2026.9.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ad/60/8674394ce43f4dadae573a1d6f451716438e9eab7d7fe8d643c673a32d85/yarl-1.25.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/ad/9b/9fb3585dabcd73a1b2a6267f63f62649347c9e6d072c9fde365b105abb2c/build-1.6.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/ae/d4/22cbe288c0d5cef7620503be92c0098d82220353fc7e188034a19c517240/websockets-17.2-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b5/55/fdd01894807d20fb6181f13590ae247585b10e909b42e6ada833379fe520/plumbum-2.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b5/d4/8e58a7221419bf8cb194f362ab26abf504c983135975e4c785fac0852886/flake8-7.4.1-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/b6/a8/7df0a3b4f8166c2fb7fe0195f88a0a643cfb7cd1b0430c19f74bd01771f5/pyflakes-4.0.2-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/bc/7b/ca212cbe170ac8b96e45317ecbcf9c3c3ecf0cdec98d5b088a9c4088929b/yarl-1.25.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/bd/59/6b1daa3b94de8970e2a2787ba73616c2d0675d2f948ef4cad8bef7f21bc6/cssselect2-0.10.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/be/02/bce7a1d70e9d0f6ea8623c16386138c251b5855af6cd3a11433d3fba4e66/semver-3.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c5/0a/1423bb61b5f06376fe6ccf79c8781e2bb5e36c78c1814c78913b7425950f/bokeh-3.10.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/c5/9b/6ce1ead737fe496611bda600c263b9a11ae7bd8f41bb13f9bd7e0a2c37a4/platformdirs-4.12.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c7/06/dace3e27af26690cb20bead80dbac42941b0841eb689b8aabbd67dde16f0/pymupdf-1.28.2-cp310-abi3-manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/c7/f0/1d8615d4ac47002b75072b0f4cb2101a7608ee9f6f94f1bd62717c1d7452/zeroconf-0.151.5-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/ce/4e/58b9b1460afc97a4c0b17ee89af701c4011d4d7f46470eba3aaff76a8069/propcache-0.5.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/cf/4c/c73f828fdbcd37eaf21d08fa852544a3ca7c2dbb3ea76873d64f2ea413d1/opencv_python-5.0.0.93-cp37-abi3-manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/d1/36/6c45f15be4eca4ac1062c6a55a323286494c99726a7e58951fe85967ac08/coverage-7.16.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/cf/f5/2fcc5e30053a938286f17d0edf3f0850b8061b984256fa7c26850b9c8809/aiohttp-3.14.5-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/d1/89/5d4c86da1130d9059681e5b6cd7645df5c10279a6a079c5c37dcb2cc6f3f/narwhals-2.27.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d7/8b/f837f52905395ba4510fe61f753c24833fb0a9c76e21267bb9f828b664a9/filelock-4.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d7/ba/18b6e682ead424ad24bcc134339ae5d1b931cd9ae260540592a058a91279/pyright-1.1.414-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/db/5b/b83a9bf1a3b4ec222f9fa083147ff6816245223da0ab92370e7e056f113f/pytest_mock-3.16.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/df/93/084f10ebbecdedf7902ed68fbecc903f0e9c5742155bfffb05eccbc071da/virtualenv-21.14.5-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/de/29/48fca82a7ebf7ff7b2e35019cc9537e7f65e4d2aa1215cc5a8792c989251/coverage-7.16.2-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/df/f3/90993aab504fa2e1f28fcc09aa16b6ea4f00e75a037d9136e737855833e2/pyvips-3.2.0.tar.gz - - pypi: https://files.pythonhosted.org/packages/e4/03/17234145f302a645a123e8c3bb2411ecf4669fbc350de3b0d803230a1729/ruff-0.16.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/e7/29/2094fd7719146ca7bb236be9751b5929d431f643cbab669aed89cf121883/ezdxf-1.4.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/ed/5b/db08419c1e1f7c9d60cfd2787b2b517d7ae4ebbda8281b48a33eb5141467/multidict-6.9.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/e4/62/c3c23511ebe3d76dc679db0bb045dfd20ed3452bec12dcbbe8d655498a7d/vtracer-0.6.15.tar.gz + - pypi: https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/fa/48/f36d233ce749d1bb880cb5e6f8bb171dc2effabc7435c31da604d831b3ec/gitpython-3.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fa/70/fffc9613501877c0a10a1ef73a165e6ded3e53f6e7502227fef56d973933/identify-2.6.20-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/ff/72/b12534f2d335448bc98504610c8d57520e530dab840251871a72cbb6c227/vtracer-0.6.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl osx-arm64: - conda: https://conda.anaconda.org/conda-forge/noarch/adwaita-icon-theme-51.0-unix_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-hbd8a1cb_0.conda @@ -1005,18 +997,18 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-ubuntu-0.83-h77eed37_3.conda - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-ecosystem-1-0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-forge-1-hc364b38_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.3-pyhc364b38_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/poppler-data-0.4.12-hd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.21.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pyopengl-3.1.10-pyh534df25_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-9.1.1-pyhc364b38_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-9_cp314.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/rust-std-aarch64-apple-darwin-1.98.1-hf6ec828_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.15-9_cp315.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/rust-std-aarch64-apple-darwin-1.99.0-h451e77e_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-84.0.0-pyh332efcf_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/svgelements-1.9.6-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.5.0-pyh5ded981_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/_openmp_mutex-4.5-8_kmp_llvm.conda @@ -1025,7 +1017,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/atk-1.0-2.38.0-hd03087b_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/aws-c-auth-1.0.0-h7ad4001_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/aws-c-cal-1.0.0-h50d513f_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/aws-c-common-1.0.2-h74c22ad_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/aws-c-common-1.0.3-h74c22ad_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/aws-c-compression-1.0.0-h50d513f_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/aws-c-http-1.0.0-h7b2e6b5_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/aws-c-io-1.0.0-h49e100f_0.conda @@ -1042,26 +1034,26 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/fontconfig-2.18.3-h81aa574_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/freetype-2.14.3-hce30654_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/fribidi-1.0.17-hb001647_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/g-ir-build-tools-1.86.0-py314h0924591_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/g-ir-build-tools-1.86.0-py315h01a7eab_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/g-ir-host-tools-1.86.0-hab4cfce_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/gdk-pixbuf-2.44.8-h5867e2c_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/gettext-0.25.1-hae62183_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/gettext-tools-0.25.1-hae62183_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ghostscript-10.04.0-hf9b8971_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/giflib-5.2.2-hd20048c_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/giflib-6.1.3-h74c22ad_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/glib-2.90.0-h37000cc_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/glib-tools-2.90.0-h06d1d6c_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/gobject-introspection-1.86.0-py314h9e5fe9d_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/gobject-introspection-1.86.0-py315h6882d7b_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/graphite2-1.3.15-h784d473_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/graphviz-14.1.2-hec8c438_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/gtk3-3.24.52-hc0f3e19_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/gtk4-4.24.1-h7a6b1a4_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/gts-0.7.6-he42f4ea_4.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/harfbuzz-14.5.1-hce30654_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/harfbuzz-14.6.0-hce30654_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/hdf5-2.2.0-nompi_h9376f17_101.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/hicolor-icon-theme-0.17-hce30654_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-78.3-py310h579977c_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/imagemagick-7.1.2_31-agpl_h07d2cc2_101.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/imagemagick-7.1.2_31-agpl_hca089ec_102.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/imath-3.2.3-h83d81d2_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/jasper-4.2.9-hc111fee_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/krb5-1.22.2-h34f8a20_2.conda @@ -1079,12 +1071,12 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlienc-1.2.0-h2ddc9cb_4.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcblas-3.11.0-11_hb0561ab_openblas.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcurl-8.22.0-h6651222_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-23.1.2-h55c6f16_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-23.1.3-h55c6f16_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libdav1d7-1.5.4-h74c22ad_4.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libde265-1.1.1-h3feff0a_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libde265-1.1.3-hfbe1efa_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libdeflate-1.25-he7e0567_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libdicom-1.3.0-h84a0fba_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libedit-3.1.20250104-pl5321h26f1114_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libedit-3.1.20260512-pl5321h26f1114_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libev-4.33-h1a92334_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexif-0.6.26-h1a92334_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.8.5-h6111c0a_0.conda @@ -1101,9 +1093,9 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgirepository-1.86.0-hccdf2ab_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libglib-2.90.0-hccd281b_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgraphene-1.10.8-h77cb426_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libharfbuzz-14.5.1-hcda0f7c_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libharfbuzz-devel-14.5.1-hcda0f7c_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libheif-1.23.5-gpl_hea44f65_100.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libharfbuzz-14.6.0-hcda0f7c_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libharfbuzz-devel-14.6.0-hcda0f7c_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libheif-1.23.6-gpl_hea44f65_100.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libhwy-1.4.0-hd2bdd19_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libiconv-1.18-he4c29f2_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libintl-0.25.1-hae62183_2.conda @@ -1118,30 +1110,29 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenblas-0.3.34-openmp_h5e6e99c_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libpng-1.6.59-hf5e6511_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libpsl-0.23.1-hdb0c161_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libpython-3.14.7-h4311e70_106_cp314.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libraw-0.22.2-h2d05ff4_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/librsvg-2.62.4-hc8ac265_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.53.4-h34a968b_102.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.54.0-hca69786_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libssh2-1.11.1-hbf2880b_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libtiff-4.7.2-hf67920b_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libvips-8.18.7-h588ea66_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libvulkan-loader-1.4.357.0-hfbe1efa_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libwebp-base-1.6.0-h202fb40_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxcb-1.17.0-hbffa61f_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-16-2.15.4-heb56d2d_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-2.15.4-h550178d_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-16-2.15.4-heb56d2d_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-2.15.4-h550178d_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxmlb-0.3.29-h10573d7_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzip-1.11.4-hcd2b5ac_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_3.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvm-openmp-23.1.2-hdb3d66b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvm-openmp-23.1.3-hdb3d66b_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/lz4-c-1.10.0-hdca3b7d_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/lzo-2.10-h74c22ad_1003.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ministream-0.99.1-hab76ae8_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.6-he64c551_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/nspr-4.40-hdcbdcf5_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/nss-3.118-h1c710a3_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/numpy-2.5.3-py314he06036b_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openexr-3.5.2-hd7663ec_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/numpy-2.5.4-py315h39cfc7d_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openexr-3.5.2-h1458d73_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openjpeg-2.5.4-h4d1e80c_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openjph-0.32.0-h16ef464_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openslide-4.0.1-h8cefcf1_0.conda @@ -1153,15 +1144,14 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pkg-config-0.29.2-he0b766d_1013.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/poppler-26.07.0-h4cfec15_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pthread-stubs-0.4-hb001647_1004.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pycairo-1.29.1-py314h042460f_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pygobject-3.58.0-py314h098b748_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyopengl-accelerate-3.1.10-py314hdd70a12_3.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.14.7-h0ae1c2c_106_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pycairo-1.29.2-py315ha70f204_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pygobject-3.58.1-py315ha5599b7_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyopengl-accelerate-3.1.10-py315he30f02c_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.15.0-h2e4dbab_100_cp315.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/rav1e-0.8.1-h8246384_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h8b90a29_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/rust-1.98.1-h4ff7c5d_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/rust-1.99.0-haf00ee1_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/s2n-1.7.7-h430f396_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/scipy-1.18.1-py314hd9f3d4e_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/scipy-1.18.1-py315he7dc495_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/svt-av1-4.2.0-h484c67d_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-hbeba79b_4.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/x265-3.5-h55d2f36_4.conda @@ -1179,75 +1169,75 @@ environments: - pypi: https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/03/43/344fbf03470b38c11c334fd1ea9f12212036c93f64887d1f31672a95fd8d/ruida_pa-0.22.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz - pypi: https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/12/83/c745b210ec49379ebfe627e166b527f44671a1f6ec5e1e219d91caa8964d/fonttools-4.66.1-cp314-cp314-macosx_10_15_universal2.whl + - pypi: https://files.pythonhosted.org/packages/12/ee/2fba0aecb52e7020e189f684148783aa0b9cfa3b3bfb0b400646eef70ad4/yarl-1.25.1-cp315-cp315-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/13/d4/1152d1c7ab42d8b908be64fd200ddc870dc9d4925e951198702084aa1a7d/linkify_it_py-2.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/16/67/1f0b582e73ce46511f2799cbdcdbdf9ca98f344f2aad42ff5ff5dde0d8fe/narwhals-2.27.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/16/7d/e9ffbadfbf89c93848412d04594135c4ae8c1d37d9e053b9c3ed718fabc4/python_discovery-1.6.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/15/56/50cf2a45c6163edafd737e2fd555103a26ce6748e1e241fb56ed445ea835/msgpack-1.2.3-cp315-cp315-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/17/fe/77f4a24264e728fd95542e53f026a91249b51d1611087b8c82d3a033ea97/asyncudp-0.11.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/18/42/a945f65cc61c739ec80f4112c4b78ed1791f25d33f45f19389c9c9e247e2/pypdf-6.20.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/29/5c/969a1b72692055fd2a419590c41847ec9144fefb97b75ff1cde5b6372891/aiohttp-3.14.5-cp315-cp315-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/31/38/df472520f3e626524d7e2fc9d6da0afe7895a2f1489d36b48af8ca40bb41/coverage-7.16.2-cp315-cp315-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/3c/2c/c43c03eaf630435f023f1dc61ec4a4a78951ad5530a62c71cc89bde307b7/pypdf-6.19.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3f/99/2e119d541d596daea39643eb9312b47c7847383951300f889166938035b1/rpyc-6.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/41/51/8041c2e70649e5b7f2a0aedbbbd0609ac099cfaa0cbde2014279c9c05756/cairosvg-2.9.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/44/b0/554d720d71083ccd24ba2f376c048544c073570e7bb18b34d769cef77f66/pyflakes-4.0.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/45/e2/bbb7129c9e7999a6b8ee9cca3b66486c25c423ab5a75f34071798b74ce94/pre_commit-4.6.2-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/4a/4d/2983a9fc0a412a09151299d1ec9eb7511e3d577f7213e19395fabebf9db6/filelock-4.0.11-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/52/5b/799c3e5b8e04d5fa1c9b61a4c1f5079603e747205c7311b92abd30283df5/virtualenv-21.14.7-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/54/c8/12811c9b48fde162bb72b6f2e78fada9a247a09d7bb5be2050a5d099c77b/nodeenv-1.11.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/56/e2/480bcdb153feecde4d736b18c908c7eee4846f11d7547d2e0c71499af630/pycodestyle-2.15.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/5c/09/1abf942723c0001d9c2fca1551907dade6304517b982b0bf10bba107fa81/websockets-17.2-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/60/45/c7b5c3168458db837e8ceab06dc77824e18202679d0463f0e8f002143a97/tinycss2-1.5.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/61/3b/50ed32583bfffa737df67d33b9c15d7b25df232c55ab606d059f610ef6ca/python_discovery-1.6.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/6e/bb/dda344765ffd3430afe1a1c66c866a57fae67786537d4f14607df6505ac1/yarl-1.25.1-cp314-cp314-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/66/06/bc7b944f81514378b2c2ab96c17df19e871cd33b9be0f1f6dfc975457e5e/websockets-17.2-cp315-cp315-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/72/e2/36f043cdf3970bc9f07cbd0af0263e40681d5a4693e9bb250b29aa425fae/zeroconf-0.151.5-cp314-cp314-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/75/9e/e3785ba3ecba2bd11efc74bfe2801ca4b78c4480b15a375648d809a59da3/coverage-7.16.2-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/77/c6/040cbc72480d789a5f40d63fb484d3106554c4dfa2d2b70ad5022057750f/webencodings-0.6.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/85/11/044d1ae1b4ec0d7af88ee5bc91e081be1022533032b906a7bdabdbb60977/pyproject_hooks-1.3.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/8c/be/bf49399ad2e788121f595903a56447d4b778d67acbb01ecfc1c6d5cf91f6/pygobject_stubs-2.17.0.tar.gz + - pypi: https://files.pythonhosted.org/packages/8d/15/1633010b26e88e872c93b67c0b6c5e174fb74cb6fb5c1472b4d51d4a8f22/platformdirs-4.13.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8f/f6/fb68b8c08f864ecee8f7f7ee2f1203ed5cba2335215de69e4f621eb47db5/trimesh-5.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/90/11/0e6f11117525ff0eec40ebac3d313376f102df93ca44ad9e893ee85e4f89/pycparser-3.11-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/93/d8/ba13451aa6b745c49536e87b6bf8f629b950e84bd0e8308f7dc6883b67e2/cairocffi-1.7.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/96/24/3ec292b44064c16229e064d770b2625bd8ea941aa61f44905a9fa44942c0/markupsafe-3.0.4-cp315-cp315-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9c/1f/19ebc343cc71a7ffa78f17018535adc5cbdd87afb31d7c34874680148b32/ifaddr-0.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9c/75/76f6ade78f6102c61034f828e2a22616708df2c9504bc8d6af9dd8f73dc5/opencv_python-5.0.0.93-cp37-abi3-macosx_13_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/9f/16/1a5a4a2657effe4806110f2b907313802f1367fcdbb29e8122766e407fab/ruff-0.16.10-py3-none-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/a0/09/5ecb6f82d35a2f4a4334d0a608fcf764827fa69dccdf5987ce309c16b6c1/ezdxf-1.4.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/a0/71/551608543ee5d590f7e8d522267665d6d9946866ad2a2a70a770f7c70793/msgpack-1.2.3-cp314-cp314-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/a5/1a/ad561f99f90884089e6403b76c220610809429ba868a81a2e7ce115d32e0/propcache-0.5.4-cp315-cp315-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a7/20/643f3c8f75594f937b2bf74801241c56a2e2b8e139d24dff8b66b28cdd7f/ruff-0.17.0-py3-none-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/a8/67/7094dfc74bd9b7263a71a1a5706965750600d7cf740795ce123860986206/zeroconf-0.151.5-cp315-cp315-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/a8/71/9219fe65ef32bddc0b5d14e1d98dfefe9d2c9c1b1d1e93efca1f29fcbd4d/xyzservices-2026.9.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/ad/40/e2d117b048d47282ade906fbfd92814cbee5647afc13fda88a3406039372/markupsafe-3.0.4-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/ad/9b/9fb3585dabcd73a1b2a6267f63f62649347c9e6d072c9fde365b105abb2c/build-1.6.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/b5/55/fdd01894807d20fb6181f13590ae247585b10e909b42e6ada833379fe520/plumbum-2.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b5/d4/8e58a7221419bf8cb194f362ab26abf504c983135975e4c785fac0852886/flake8-7.4.1-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/b6/a8/7df0a3b4f8166c2fb7fe0195f88a0a643cfb7cd1b0430c19f74bd01771f5/pyflakes-4.0.2-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b6/83/cf690ab80a0db0f50b300a7141f45dbf1bbbb6b32457f74bb55f47d38f5c/multidict-7.1.0-cp315-cp315-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/bd/59/6b1daa3b94de8970e2a2787ba73616c2d0675d2f948ef4cad8bef7f21bc6/cssselect2-0.10.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/be/02/bce7a1d70e9d0f6ea8623c16386138c251b5855af6cd3a11433d3fba4e66/semver-3.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c5/0a/1423bb61b5f06376fe6ccf79c8781e2bb5e36c78c1814c78913b7425950f/bokeh-3.10.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/c5/9b/6ce1ead737fe496611bda600c263b9a11ae7bd8f41bb13f9bd7e0a2c37a4/platformdirs-4.12.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/c8/c3/0bebaaec98aa7cb43e04a8a6079b4aecb83338f6b4b3a32eb837c4c484ea/raydriver-0.3.0-cp311-abi3-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/ca/c6/22c009b147de23fb8d1b18587ec303ab99fe4af423802185a2991a0a5bc4/vtracer-0.6.15-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/cd/5b/ff5fc58fa2427c30dea74c90053f4fc5eda1e7f3833ed3ecc7147fe2b311/tornado-6.5.10-cp39-abi3-macosx_10_9_universal2.whl + - pypi: https://files.pythonhosted.org/packages/d1/89/5d4c86da1130d9059681e5b6cd7645df5c10279a6a079c5c37dcb2cc6f3f/narwhals-2.27.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d7/8b/f837f52905395ba4510fe61f753c24833fb0a9c76e21267bb9f828b664a9/filelock-4.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d7/ba/18b6e682ead424ad24bcc134339ae5d1b931cd9ae260540592a058a91279/pyright-1.1.414-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/db/5b/b83a9bf1a3b4ec222f9fa083147ff6816245223da0ab92370e7e056f113f/pytest_mock-3.16.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/df/93/084f10ebbecdedf7902ed68fbecc903f0e9c5742155bfffb05eccbc071da/virtualenv-21.14.5-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/df/f3/90993aab504fa2e1f28fcc09aa16b6ea4f00e75a037d9136e737855833e2/pyvips-3.2.0.tar.gz - - pypi: https://files.pythonhosted.org/packages/e2/22/b533b493d7025456f44518b33e53e000021a20fe7c27b88cf3d341df7186/propcache-0.5.4-cp314-cp314-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/f9/b4/8e6d950f02ca6ecc00ecf671f2ddb5dab7017671a8d197326f75621f5786/multidict-6.9.1-cp314-cp314-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/e4/62/c3c23511ebe3d76dc679db0bb045dfd20ed3452bec12dcbbe8d655498a7d/vtracer-0.6.15.tar.gz + - pypi: https://files.pythonhosted.org/packages/eb/95/235679d5fe4265c251418cd02321de069281a700415389e14c4cce442e3d/fonttools-4.66.1-cp315-cp315-macosx_10_15_universal2.whl - pypi: https://files.pythonhosted.org/packages/fa/01/3591f781b417b382a8487a2356e927acfe858b1043bab0ec47f6805bb109/pymupdf-1.28.2-cp310-abi3-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/fa/48/f36d233ce749d1bb880cb5e6f8bb171dc2effabc7435c31da604d831b3ec/gitpython-3.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fa/70/fffc9613501877c0a10a1ef73a165e6ded3e53f6e7502227fef56d973933/identify-2.6.20-py2.py3-none-any.whl @@ -1288,10 +1278,10 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/krb5-1.22.2-hbc21106_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/lame-4.0-h770b6ad_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/lcms2-2.19.1-h9073bf1_3.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.46.1-default_hbd61a6d_102.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.46.1-default_hbd61a6d_103.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/lerc-4.2.0-hdb68285_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/level-zero-1.33.1-h7148c6a_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libabseil-20260526.0-cxx17_h0dc7533_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libabseil-20260526.0-cxx17_h0dc7533_3.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libadwaita-1.10.0-h94ee597_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libasprintf-0.25.1-h0e23cf9_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libass-0.17.5-h3b6f6bf_1.conda @@ -1304,7 +1294,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/libdeflate-1.25-hd45a770_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libdovi-3.4.0-h39d0f39_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libdrm-2.4.129-h7cc23a3_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20250104-pl5321h373387f_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20260512-pl5321h373387f_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libegl-1.7.0-ha4b6fd6_5.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libegl-devel-1.7.0-ha4b6fd6_5.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.5-hd2095e1_0.conda @@ -1322,7 +1312,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/libglx-devel-1.7.0-ha4b6fd6_5.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-16.2.0-he0feb66_7.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libgraphene-1.10.8-h23b58f8_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libharfbuzz-14.5.1-h23af247_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libharfbuzz-14.6.0-h23af247_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libhwloc-2.15.0-default_hd7c4d0c_1001.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libhwy-1.4.0-h57c4cff_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libiconv-1.18-h0cb94f2_3.conda @@ -1350,10 +1340,9 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/libplacebo-7.360.1-hc50b9dd_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libpng-1.6.59-h922cc85_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libprotobuf-7.35.1-h622638d_3.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libpython-3.14.7-hdc7f604_106_cp314.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/librsvg-2.62.4-ha427ee3_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libsndfile-1.2.2-hbc6d301_3.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.4-h0737f62_102.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.54.0-h13e7031_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-16.2.0-h934c35e_7.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libsystemd0-262-h26e0ef7_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libtiff-4.7.2-hcc2c06a_1.conda @@ -1370,8 +1359,8 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/libwebp-base-1.6.0-hd42ef1d_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libxcb-1.17.0-hb83e432_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libxkbcommon-1.13.2-h51789e4_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-16-2.15.4-hf3af7cc_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.15.4-h7df9aa5_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-16-2.15.4-hf3af7cc_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.15.4-h7df9aa5_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_3.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/ministream-0.99.1-hca128c4_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/mpg123-1.33.7-h877a99e_1.conda @@ -1386,13 +1375,12 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/pthread-stubs-0.4-h7cc23a3_1004.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/pugixml-1.15-h3f63f65_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/pulseaudio-client-17.0-hb10c1b2_4.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.14.7-hcd007b5_106_cp314.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-hd6e31c0_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.15.0-h6e52b48_100_cp315.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/sdl2-2.32.56-h54a6638_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/sdl3-3.4.18-h5330f5c_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/shaderc-2026.3-hcebf71c_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/snappy-1.2.2-h34e00bb_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/spirv-tools-2026.3-h7148c6a_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/spirv-tools-2026.4-h7148c6a_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/svt-av1-4.2.0-hd2095e1_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/tbb-2023.1.0-haa8c3ee_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h1df4ec4_4.conda @@ -1426,10 +1414,10 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-ubuntu-0.83-h77eed37_3.conda - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-ecosystem-1-0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-forge-1-hc364b38_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-9_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.15-9_cp315.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/wayland-protocols-1.49-hd8ed1ab_0.conda - - pypi: https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl osx-arm64: - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-hbd8a1cb_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2 @@ -1438,7 +1426,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-ubuntu-0.83-h77eed37_3.conda - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-ecosystem-1-0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-forge-1-hc364b38_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-9_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.15-9_cp315.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/aom-3.14.1-pl5321hf79ea98_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-h4e30115_10.conda @@ -1459,17 +1447,18 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/lame-4.0-hef9b5c2_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/lcms2-2.19.1-hce41798_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/lerc-4.2.0-h1eee2c3_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libabseil-20260526.0-cxx17_h4c27e2a_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libabseil-20260526.0-cxx17_h4c27e2a_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libadwaita-1.10.0-h27f3ac1_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libasprintf-0.25.1-hae62183_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libass-0.17.5-h0a551c8_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlicommon-1.2.0-h1dcdb26_4.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlidec-1.2.0-h5295a6a_4.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlienc-1.2.0-h2ddc9cb_4.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-23.1.2-h55c6f16_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-23.1.3-h55c6f16_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libdav1d7-1.5.4-h74c22ad_4.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libdeflate-1.25-he7e0567_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libdovi-3.4.0-hc336dc4_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libedit-3.1.20260512-pl5321h26f1114_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.8.5-h6111c0a_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.7.0-h47dc5ef_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libfreetype-2.14.3-hce30654_2.conda @@ -1477,7 +1466,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgettextpo-0.25.1-hae62183_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libglib-2.90.0-hccd281b_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgraphene-1.10.8-h77cb426_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libharfbuzz-14.5.1-hcda0f7c_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libharfbuzz-14.6.0-hcda0f7c_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libhwloc-2.15.0-default_h98d0dc1_1001.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libhwy-1.4.0-hd2bdd19_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libiconv-1.18-he4c29f2_3.conda @@ -1503,17 +1492,16 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libplacebo-7.360.1-hca394fb_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libpng-1.6.59-hf5e6511_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libprotobuf-7.35.1-h391e224_3.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libpython-3.14.7-h4311e70_106_cp314.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/librsvg-2.62.4-hc8ac265_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.53.4-h34a968b_102.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.54.0-hca69786_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libtiff-4.7.2-hf67920b_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libusb-1.0.30-h74c22ad_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libvorbis-1.3.7-h81086ad_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libvpx-1.17.0-h484c67d_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libvulkan-loader-1.4.357.0-hfbe1efa_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libwebp-base-1.6.0-h202fb40_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-16-2.15.4-heb56d2d_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-2.15.4-h550178d_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-16-2.15.4-heb56d2d_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-2.15.4-h550178d_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ministream-0.99.1-hab76ae8_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/mpg123-1.33.7-h0e3f465_1.conda @@ -1524,20 +1512,19 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pcre2-10.47-he63d830_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pixman-0.46.4-h784d473_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pugixml-1.15-hd3d436d_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.14.7-h0ae1c2c_106_cp314.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h8b90a29_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.15.0-h2e4dbab_100_cp315.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/sdl2-2.32.56-h784d473_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/sdl3-3.4.18-h4bb5895_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/shaderc-2026.3-hfd4ff19_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/snappy-1.2.2-h3951085_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/spirv-tools-2026.3-hc0b298b_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/spirv-tools-2026.4-hc0b298b_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/svt-av1-4.2.0-h484c67d_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tbb-2023.1.0-h81c76ee_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-hbeba79b_4.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/x264-1!164.3095-hb001647_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/x265-3.5-h55d2f36_4.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hf451053_7.conda - - pypi: https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl website: channels: - url: https://conda.anaconda.org/conda-forge/ @@ -1562,7 +1549,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/keyutils-1.6.3-h7cc23a3_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/krb5-1.22.2-hbc21106_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/lerc-4.2.0-hdb68285_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libabseil-20260526.0-cxx17_h0dc7533_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libabseil-20260526.0-cxx17_h0dc7533_3.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libadwaita-1.10.0-h94ee597_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libasprintf-0.25.1-h0e23cf9_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlicommon-1.2.0-h39a168f_4.conda @@ -1571,7 +1558,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/libcups-2.3.3-h7a8fb5f_6.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libdeflate-1.25-hd45a770_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libdrm-2.4.129-h7cc23a3_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20250104-pl5321h373387f_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20260512-pl5321h373387f_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libegl-1.7.0-ha4b6fd6_5.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libegl-devel-1.7.0-ha4b6fd6_5.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libev-4.33-h280c20c_3.conda @@ -1589,7 +1576,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/libglx-devel-1.7.0-ha4b6fd6_5.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-16.2.0-he0feb66_7.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libgraphene-1.10.8-h23b58f8_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libharfbuzz-14.5.1-h23af247_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libharfbuzz-14.6.0-h23af247_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libiconv-1.18-h0cb94f2_3.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libjpeg-turbo-3.2.0-hb03c661_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_1.conda @@ -1597,7 +1584,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/libpciaccess-0.19-hb03c661_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libpng-1.6.59-h922cc85_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/librsvg-2.62.4-ha427ee3_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.4-h0737f62_102.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.54.0-h13e7031_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-16.2.0-h934c35e_7.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libtiff-4.7.2-hcc2c06a_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42.4-hcfc3c73_0.conda @@ -1606,8 +1593,8 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/libwebp-base-1.6.0-hd42ef1d_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libxcb-1.17.0-hb83e432_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libxkbcommon-1.13.2-h51789e4_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-16-2.15.4-hf3af7cc_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.15.4-h7df9aa5_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-16-2.15.4-hf3af7cc_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.15.4-h7df9aa5_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_3.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/ministream-0.99.1-hca128c4_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_1.conda @@ -1665,13 +1652,13 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/hicolor-icon-theme-0.17-hce30654_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-78.3-py310h579977c_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/lerc-4.2.0-h1eee2c3_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libabseil-20260526.0-cxx17_h4c27e2a_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libabseil-20260526.0-cxx17_h4c27e2a_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libadwaita-1.10.0-h27f3ac1_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libasprintf-0.25.1-hae62183_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlicommon-1.2.0-h1dcdb26_4.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlidec-1.2.0-h5295a6a_4.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlienc-1.2.0-h2ddc9cb_4.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-23.1.2-h55c6f16_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-23.1.3-h55c6f16_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libdeflate-1.25-he7e0567_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libev-4.33-h1a92334_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.8.5-h6111c0a_0.conda @@ -1681,7 +1668,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgettextpo-0.25.1-hae62183_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libglib-2.90.0-hccd281b_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgraphene-1.10.8-h77cb426_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libharfbuzz-14.5.1-hcda0f7c_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libharfbuzz-14.6.0-hcda0f7c_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libiconv-1.18-he4c29f2_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libintl-0.25.1-hae62183_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libjpeg-turbo-3.2.0-h84a0fba_1.conda @@ -1689,12 +1676,12 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libnghttp2-1.68.1-h435687b_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libpng-1.6.59-hf5e6511_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/librsvg-2.62.4-hc8ac265_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.53.4-h34a968b_102.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.54.0-hca69786_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libtiff-4.7.2-hf67920b_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libuv-1.52.1-h1a92334_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libvulkan-loader-1.4.357.0-hfbe1efa_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libwebp-base-1.6.0-h202fb40_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-16-2.15.4-heb56d2d_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-16-2.15.4-heb56d2d_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ministream-0.99.1-hab76ae8_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/nodejs-26.10.0-h8d74fab_0.conda @@ -1822,19 +1809,19 @@ packages: - atk-1.0 >=2.38.0 size: 355900 timestamp: 1713896169874 -- conda: https://conda.anaconda.org/conda-forge/linux-64/binutils_impl_linux-64-2.46.1-default_hfdba357_102.conda - sha256: fb7bf36984a37ce7e4714d1d1da0bd0e3bfc679520f5cdc184afc676fd4b5da2 - md5: a0c5e0b7f58c8ceeb08e5bc41251d5a2 +- conda: https://conda.anaconda.org/conda-forge/linux-64/binutils_impl_linux-64-2.46.1-default_hfdba357_103.conda + sha256: 98f9ddc6dc5d56a824b7eaea5bab6ad4bb47642288fc70d6b1b4024ac39d5595 + md5: e86f9dc615d7533c5be8b15b3a1dee8d depends: - - ld_impl_linux-64 2.46.1 default_hbd61a6d_102 + - ld_impl_linux-64 2.46.1 default_hbd61a6d_103 - sysroot_linux-64 - zstd >=1.5.7,<1.6.0a0 license: GPL-3.0-only license_family: GPL purls: [] run_exports: {} - size: 3713752 - timestamp: 1784214522814 + size: 3706604 + timestamp: 1791565481839 - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_10.conda sha256: 1a0d382c515ebf55f8ee1f38c8b81bc95af5c2acc42ad53b66bc5df932032f96 md5: e675fabcf81499adc7edf58124fb1e01 @@ -2123,23 +2110,23 @@ packages: - fribidi >=1.0.17,<2.0a0 size: 64102 timestamp: 1790011855888 -- conda: https://conda.anaconda.org/conda-forge/linux-64/g-ir-build-tools-1.86.0-py312h356d82a_3.conda - sha256: b4576aedd6809a9d8ffb1f799088cfd3e6bc8082a6fd1734fe84b5ef50490d1c - md5: 3ff32e7da4453f32e4291644450e789c +- conda: https://conda.anaconda.org/conda-forge/linux-64/g-ir-build-tools-1.86.0-py315h4fefd22_3.conda + sha256: 69a91181e240a46d065cdf6b077a5a3f8d72f2c90189f8525780fb02f77fae27 + md5: 436e2e29041b030a0943f5292eb926dc depends: - __glibc >=2.17,<3.0.a0 - libgcc >=15 - libglib >=2.88.3,<3.0a0 - pkg-config - - python >=3.12,<3.13.0a0 - - python_abi 3.12.* *_cp312 + - python >=3.15.0rc2,<3.16.0a0 + - python_abi 3.15.* *_cp315 - setuptools license: LGPL-2.0-or-later license_family: LGPL purls: [] run_exports: {} - size: 359724 - timestamp: 1788834462783 + size: 365896 + timestamp: 1788834471900 - conda: https://conda.anaconda.org/conda-forge/linux-64/g-ir-host-tools-1.86.0-h5297791_3.conda sha256: f7c3c809f237270e06b6ba1663285c601ea84e0ddf4dc1ef436d1a3802a1ca15 md5: fd98cadc0fc11641ea8a76661655398b @@ -2239,20 +2226,20 @@ packages: run_exports: {} size: 62149156 timestamp: 1788878278764 -- conda: https://conda.anaconda.org/conda-forge/linux-64/giflib-5.2.2-ha257d8a_1.conda - sha256: 3611c5d605cd56017704e6c01c7bd849e74a656a546d4c49d655fde49ddfeecc - md5: bd7c3a8586ed0101f094962100d30a63 +- conda: https://conda.anaconda.org/conda-forge/linux-64/giflib-6.1.3-hebe6cf0_2.conda + sha256: 917c3a622c3870f822d25a882f1856e757d91e31a3dd52a499849a392ca865d2 + md5: 07bdd65afe03df3fb5dbcaa8257c64a3 depends: + - libgcc >=15 - __glibc >=2.17,<3.0.a0 - - libgcc >=14 license: MIT license_family: MIT purls: [] run_exports: weak: - - giflib >=5.2.2,<5.3.0a0 - size: 77403 - timestamp: 1784694210187 + - giflib >=6.1.3,<6.2.0a0 + size: 86117 + timestamp: 1788948683783 - conda: https://conda.anaconda.org/conda-forge/linux-64/glib-2.90.0-h5e3c387_0.conda sha256: 4d3ddb32831bbd89d4c1b5282930f70a1bf0e1210c772d2df4c13b3eb12f577f md5: 783f9086aaff57b304816dc67181e3f7 @@ -2311,26 +2298,26 @@ packages: - gmp >=6.3.0,<7.0a0 size: 493498 timestamp: 1786629164954 -- conda: https://conda.anaconda.org/conda-forge/linux-64/gobject-introspection-1.86.0-py312h1e02d69_3.conda - sha256: 3f4312699fbd9647418d08aca8ccac6ef93297ffedf34228b5bf8765662ba7c0 - md5: 50d0e1111f4c7aace2d3fe20abbceb37 +- conda: https://conda.anaconda.org/conda-forge/linux-64/gobject-introspection-1.86.0-py315ha69db51_3.conda + sha256: 6fa8414e9101be8b64e68ef32164d977507a585dbc857d16039c9c3f220c237b + md5: 183df736d01770482d9a1fffd066889f depends: - __glibc >=2.17,<3.0.a0 - cairo >=1.18.4,<2.0a0 - - g-ir-build-tools 1.86.0 py312h356d82a_3 + - g-ir-build-tools 1.86.0 py315h4fefd22_3 - g-ir-host-tools 1.86.0 h5297791_3 - libffi >=3.7.0,<3.8.0a0 - libgcc >=15 - libgirepository 1.86.0 h6d5faa7_3 - libglib >=2.88.3,<3.0a0 - - python >=3.12,<3.13.0a0 - - python_abi 3.12.* *_cp312 + - python >=3.15.0rc2,<3.16.0a0 + - python_abi 3.15.* *_cp315 license: LGPL-2.0-or-later license_family: LGPL purls: [] run_exports: {} - size: 146214 - timestamp: 1788834492362 + size: 146619 + timestamp: 1788834501500 - conda: https://conda.anaconda.org/conda-forge/linux-64/graphite2-1.3.15-h54a6638_1.conda sha256: 7fa3b6a9c081fa3e545573152a788d061a0a0ba57df7251cc0f4f75225fc93e7 md5: f9fe2984587fa8235a6af6004760cd18 @@ -2490,19 +2477,19 @@ packages: - gts >=0.7.6,<0.8.0a0 size: 318312 timestamp: 1686545244763 -- conda: https://conda.anaconda.org/conda-forge/linux-64/harfbuzz-14.5.1-ha770c72_0.conda - sha256: cc0b3cc8e0200637d36ff51c865f7a83fefc9036347bdab21db4a721021947c2 - md5: efe22611632c03e6dc124a6b19f788cc +- conda: https://conda.anaconda.org/conda-forge/linux-64/harfbuzz-14.6.0-ha770c72_1.conda + sha256: 91b07a1ea0d353ffb0b98fda1806e96ba47bdb89f823920169e97a3af2a30301 + md5: c4ba2eba9b3746ef646cb1f11e2eff33 depends: - - libharfbuzz-devel 14.5.1 h23af247_0 + - libharfbuzz-devel 14.6.0 h23af247_1 license: MIT license_family: MIT purls: [] run_exports: weak: - - libharfbuzz >=14.5.1 - size: 11031 - timestamp: 1790860750999 + - libharfbuzz >=14.6.0 + size: 11103 + timestamp: 1791336162823 - conda: https://conda.anaconda.org/conda-forge/linux-64/hdf5-1.14.6-nompi_h19486de_110.conda sha256: ac57ce3abbda2a82d6192bc36fbd7fe96e867d84c999ef81a3da034dfd01a995 md5: 9c6e11a83468e1d5decae516077a73fb @@ -2548,9 +2535,9 @@ packages: - icu >=78.3,<79.0a0 size: 14459115 timestamp: 1786545741408 -- conda: https://conda.anaconda.org/conda-forge/linux-64/imagemagick-7.1.2_31-agpl_hab780e9_101.conda - sha256: b493c8d7954fcaae274a239435677bb722b3e00d82f4bc2a15c8f580ca8e252d - md5: 4aa2f72bed01efa2dfe97e7dbc291c8b +- conda: https://conda.anaconda.org/conda-forge/linux-64/imagemagick-7.1.2_31-agpl_hde3faa9_102.conda + sha256: 124b7048db14adffecee8ddbecbf07847ecc56dfd957f24ee127591e4971e961 + md5: 59158ba36986b95a513b58ad4579dbe8 depends: - __glibc >=2.17,<3.0.a0 - bzip2 >=1.0.8,<2.0a0 @@ -2559,12 +2546,12 @@ packages: - fonts-conda-ecosystem - fonts-conda-forge - ghostscript - - giflib >=5.2.2,<5.3.0a0 + - giflib >=6.1.3,<6.2.0a0 - graphviz >=14.1.2,<15.0a0 - lcms2 >=2.19.1,<3.0a0 - libgcc >=15 - libglib >=2.90.0,<3.0a0 - - libheif >=1.23.5,<1.24.0a0 + - libheif >=1.23.6,<1.24.0a0 - libjpeg-turbo >=3.2.0,<4.0a0 - libjxl >=0.12.0,<0.13.0a0 - liblzma >=5.8.3,<6.0a0 @@ -2589,8 +2576,8 @@ packages: license_family: AGPL purls: [] run_exports: {} - size: 2622005 - timestamp: 1791026447529 + size: 2621733 + timestamp: 1791295176440 - conda: https://conda.anaconda.org/conda-forge/linux-64/imath-3.2.3-h923cf36_1.conda sha256: 2bbf9d66ffca314d322e5461379cbde1066e49c6e370aaa9aa575306100ddbc8 md5: 6ca98542ae81e8705f2bfd30dd1e646f @@ -2722,9 +2709,9 @@ packages: - lcms2 >=2.19.1,<3.0a0 size: 254292 timestamp: 1789051947923 -- conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.46.1-default_hbd61a6d_102.conda - sha256: 27d83f1188cd19bcb7754a078b3fa7f4cfb8527f8eb2fde54dd01fc529d1adec - md5: 449500f2c089da11c40f5c21312e3e07 +- conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.46.1-default_hbd61a6d_103.conda + sha256: 901649b1edbe841fd4ee7e2d491a05184ed540eb01cdae506872f344babc1d28 + md5: 2362c70e7e79827c32db35547d9c179b depends: - __glibc >=2.17,<3.0.a0 - zstd >=1.5.7,<1.6.0a0 @@ -2734,8 +2721,8 @@ packages: license_family: GPL purls: [] run_exports: {} - size: 745303 - timestamp: 1784214507189 + size: 742109 + timestamp: 1791565468810 - conda: https://conda.anaconda.org/conda-forge/linux-64/lerc-4.2.0-hdb68285_0.conda sha256: bf9fdebf55d8bc99d83531cdffda00703f4dc5f93a1a956768c147362c72feda md5: fb9d356b1a57d6d54768be7ebd5fce09 @@ -2764,9 +2751,9 @@ packages: run_exports: {} size: 780520 timestamp: 1788366449509 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libabseil-20260526.0-cxx17_h0dc7533_2.conda - sha256: 7bb3d495411b7059e85225aae500d84e7846a4bb02ebd77e4450200b20c180f0 - md5: 6983bfe8e09992014b9cf393769c7a84 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libabseil-20260526.0-cxx17_h0dc7533_3.conda + sha256: 24708450d80f4d542fc357c941909446e0b6caffb8baca478a0da1f74820e688 + md5: d224f3a53f55029366c73161f15461fb depends: - __glibc >=2.17,<3.0.a0 - libgcc >=14 @@ -2781,8 +2768,8 @@ packages: weak: - libabseil >=20260526.0,<20260527.0a0 - libabseil =*=cxx17* - size: 1436888 - timestamp: 1787217011773 + size: 1440678 + timestamp: 1791681043356 - conda: https://conda.anaconda.org/conda-forge/linux-64/libadwaita-1.10.0-h94ee597_0.conda sha256: 82edf4489ec7910453badfb171026a123711809b1fbd3088858b92ed564a16bc md5: bd628d5ace94d8fe70e9687b73780a93 @@ -3056,21 +3043,21 @@ packages: run_exports: {} size: 765022 timestamp: 1788366542408 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libde265-1.1.1-h171cf75_0.conda - sha256: 61ea18b0fe5cc57f6a497123a561f3c8414349be6e7ceb3ce5ff060f54d6b830 - md5: 05dd7cae10a462986a357df1102d7ae0 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libde265-1.1.3-h4dbf13b_1.conda + sha256: 1727e114c435b908374ab58e2bb9c58c6110d1c23aedfc2690a7261456f1d47b + md5: 5af60f4d9038882ff5b8d3f0769443bc depends: - - libstdcxx >=14 - - libgcc >=14 + - libstdcxx >=15 + - libgcc >=15 - __glibc >=2.17,<3.0.a0 license: LGPL-3.0-or-later license_family: LGPL purls: [] run_exports: weak: - - libde265 >=1.1.1,<1.1.2.0a0 - size: 402800 - timestamp: 1780754947451 + - libde265 >=1.1.3,<1.1.4.0a0 + size: 423467 + timestamp: 1790375100665 - conda: https://conda.anaconda.org/conda-forge/linux-64/libdeflate-1.25-hd45a770_1.conda sha256: 82e134c8a08b1eed9a2ed8ab578b89aa1730dcde3dea8dd87645ed0637878e54 md5: 40f9b31aa9cf007789867df0decd0492 @@ -3130,22 +3117,22 @@ packages: - libdrm >=2.4.129,<2.5.0a0 size: 313461 timestamp: 1786684701973 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20250104-pl5321h373387f_1.conda - sha256: 6473eb8caf2aae830f37caa93db9b26dddf7ac84b63229e8bf7fc0e5c3ab95b0 - md5: 50708d3b951d0f8e2d7f2df5b5edc040 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20260512-pl5321h373387f_0.conda + sha256: 45301f14f4eb66da57cfaf2d265f3eeee73f8b07e99614ee3afc5b26eea91126 + md5: 27dbd7e2984b8fe48e069379636ccfdd depends: - ncurses - - libgcc >=14 - __glibc >=2.17,<3.0.a0 + - libgcc >=14 - ncurses >=6.6,<7.0a0 license: BSD-2-Clause license_family: BSD purls: [] run_exports: weak: - - libedit >=3.1.20250104,<3.2.0a0 - size: 135098 - timestamp: 1786616658086 + - libedit >=3.1.20260512,<3.2.0a0 + size: 135877 + timestamp: 1791327370514 - conda: https://conda.anaconda.org/conda-forge/linux-64/libegl-1.7.0-ha4b6fd6_5.conda sha256: 13d3fde9c1dcf254968cc70652222a43f25d04e69555ccf855553110e753288e md5: 3a1b41c4591a0a1734382af3e2b8bc08 @@ -3533,9 +3520,9 @@ packages: run_exports: {} size: 123320 timestamp: 1776657405605 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libharfbuzz-14.5.1-h23af247_0.conda - sha256: eb8ee0fd0f11bcd8f73d260fd2e64c7338e7c829ed204d9abbfd9dc75e55b031 - md5: c29c810ee25f6ef4f3fefc183142a0ab +- conda: https://conda.anaconda.org/conda-forge/linux-64/libharfbuzz-14.6.0-h23af247_1.conda + sha256: a5af64065d8fb70b77ce4551c9ae022b4f170f11b789249ee6efab3e83fedbe0 + md5: bd7ae146a629d6c17e4be9365d4ec811 depends: - __glibc >=2.17,<3.0.a0 - cairo >=1.18.6,<2.0a0 @@ -3552,11 +3539,11 @@ packages: license_family: MIT purls: [] run_exports: {} - size: 1411575 - timestamp: 1790860722061 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libharfbuzz-devel-14.5.1-h23af247_0.conda - sha256: 35df8453001507228da624c8c9ae2e19f29820b7adaefe9ff2af60f7012e51e8 - md5: a47242ec6e8ecd671e01892b296e55d2 + size: 1465053 + timestamp: 1791336135892 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libharfbuzz-devel-14.6.0-h23af247_1.conda + sha256: 7c5c3f8a10ac96c8ce6f46a7bd65531317469624524a473eea73789745cf73ad + md5: e43e418e02d8f52cc7784696d3c62b6f depends: - __glibc >=2.17,<3.0.a0 - cairo >=1.18.6,<2.0a0 @@ -3567,7 +3554,7 @@ packages: - libfreetype6 >=2.14.3 - libgcc >=15 - libglib >=2.90.0,<3.0a0 - - libharfbuzz 14.5.1 h23af247_0 + - libharfbuzz 14.6.0 h23af247_1 - libpng >=1.6.59,<1.7.0a0 - libstdcxx >=15 - libzlib >=1.3.2,<2.0a0 @@ -3576,29 +3563,29 @@ packages: purls: [] run_exports: weak: - - libharfbuzz >=14.5.1 - size: 2223051 - timestamp: 1790860742329 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libheif-1.23.5-gpl_h5374add_100.conda - sha256: 1ffc56beff2b7b228afd0a3448f065311443cf582497ed87e3ab5e153814b777 - md5: 16623f2c9fc75d6f03afb98b5a200cda + - libharfbuzz >=14.6.0 + size: 2316467 + timestamp: 1791336155010 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libheif-1.23.6-gpl_h5374add_100.conda + sha256: 57708881ba259f4861e178387196ac9aae2d5a8e5285d27d840a0ecc0b66d614 + md5: 29b250be819509a19ee1717a5b469dfa depends: + - __glibc >=2.17,<3.0.a0 - libstdcxx >=15 - libgcc >=15 - - __glibc >=2.17,<3.0.a0 + - libde265 >=1.1.3,<1.1.4.0a0 - libavif16 >=1.4.2,<2.0a0 + - libdav1d7 >=1.5.4 - x265 >=3.5,<3.6.0a0 - aom >=3.14.1,<3.15.0a0 - - libde265 >=1.1.1,<1.1.2.0a0 - - libdav1d7 >=1.5.4 license: LGPL-3.0-or-later license_family: LGPL purls: [] run_exports: weak: - - libheif >=1.23.5,<1.24.0a0 - size: 1016358 - timestamp: 1790155578515 + - libheif >=1.23.6,<1.24.0a0 + size: 1017041 + timestamp: 1791239263227 - conda: https://conda.anaconda.org/conda-forge/linux-64/libhwloc-2.15.0-default_hd7c4d0c_1001.conda sha256: a140f8b94caad6ea27e9deba66a74ae2566976591db8a867677c652add6ee5a0 md5: 66c755b3bd9ec82308fa6ec7674f473f @@ -3758,20 +3745,6 @@ packages: - libnghttp2 >=1.68.1,<2.0a0 size: 649974 timestamp: 1787177490105 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libnsl-2.0.1-hb9d3cd8_1.conda - sha256: 927fe72b054277cde6cb82597d0fcf6baf127dcbce2e0a9d8925a68f1265eef5 - md5: d864d34357c3b65a4b731f78c0801dc4 - depends: - - __glibc >=2.17,<3.0.a0 - - libgcc >=13 - license: LGPL-2.1-only - license_family: GPL - purls: [] - run_exports: - weak: - - libnsl >=2.0.1,<2.1.0a0 - size: 33731 - timestamp: 1750274110928 - conda: https://conda.anaconda.org/conda-forge/linux-64/libogg-1.3.5-hd0c01bc_1.conda sha256: ffb066ddf2e76953f92e06677021c73c85536098f1c21fcd15360dbc859e22e4 md5: 68e52064ed3897463c0e958ab5c8f91b @@ -4158,32 +4131,6 @@ packages: - libpsl >=0.23.1,<0.24.0a0 size: 72519 timestamp: 1786970753847 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libpython-3.12.14-h0c77377_3_cpython.conda - build_number: 3 - sha256: 6be16a4906d83eb8e9e04375d524f339f426a847cffd294f1ceb0611988dc17a - md5: d247b7632f09324c11f24b5270361385 - depends: - - __glibc >=2.17,<3.0.a0 - - libgcc >=15 - - libstdcxx >=15 - license: Python-2.0 - purls: [] - run_exports: {} - size: 8770625 - timestamp: 1788392466381 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libpython-3.14.7-hdc7f604_106_cp314.conda - build_number: 106 - sha256: 5f879892bd439c3d94f4a97b06c214b9a19c4b92f74d84f0305c6ca1b2b239b2 - md5: 28af2158bb8dbb62e55b0af3b44f9d4a - depends: - - __glibc >=2.17,<3.0.a0 - - libgcc >=15 - - libstdcxx >=15 - license: Python-2.0 - purls: [] - run_exports: {} - size: 10520530 - timestamp: 1788383918299 - conda: https://conda.anaconda.org/conda-forge/linux-64/libraw-0.22.2-h074291d_0.conda sha256: fa3ccb18cf22f8ac94ec4f6bfcc9fd5805bf7af11cf56bf19c27fb051b36dd6a md5: a11f92bd6dd6721cce01564c7c9fdb25 @@ -4261,20 +4208,21 @@ packages: - libsndfile >=1.2.2,<1.3.0a0 size: 387942 timestamp: 1786538522787 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.4-h0737f62_102.conda - sha256: 0f6e60634de2b55da15772fe65485965fd9b61afcce0d749c1446f625babba30 - md5: 59ec6dc9fa87a6687c00969d4dcfcd97 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.54.0-h13e7031_1.conda + sha256: 4823e42fe95fc318d7af6125dc6ef33bd60c1152054d41661413250dc0e84e6c + md5: bb145547395d3329f3a1d930de9848b2 depends: - __glibc >=2.17,<3.0.a0 + - icu >=78.3,<79.0a0 - libgcc >=15 - libzlib >=1.3.2,<2.0a0 license: blessing purls: [] run_exports: weak: - - libsqlite >=3.53.4,<4.0a0 - size: 969522 - timestamp: 1791167270446 + - libsqlite >=3.54.0,<4.0a0 + size: 984364 + timestamp: 1791584378129 - conda: https://conda.anaconda.org/conda-forge/linux-64/libssh2-1.11.1-h6154650_1.conda sha256: 7e463000fa1cba3f3a6597b776f6ab301d425a96e3bc20d0d9d76a3b9f237aa3 md5: 5c526561e1d2a2e071941174eae84557 @@ -4607,19 +4555,6 @@ packages: - libxcb >=1.17.0,<2.0a0 size: 395120 timestamp: 1787885788278 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.38-h280c20c_0.conda - sha256: f7e9292dd219a6435bbb1223da9586c3e70d66d169c5a92f08db3f2127df04e9 - md5: f7a7ff5a6ab331e037abd34f379a631d - depends: - - libgcc >=14 - - __glibc >=2.17,<3.0.a0 - license: LGPL-2.1-or-later - purls: [] - run_exports: - weak: - - libxcrypt >=4.4.38 - size: 101957 - timestamp: 1785887123445 - conda: https://conda.anaconda.org/conda-forge/linux-64/libxkbcommon-1.13.2-h51789e4_1.conda sha256: d44878d396713ccf3b355df617f61c40a1442fffcf134392bc6ce0e6c2219369 md5: f888787e0eab7a8076a67d197e155ffc @@ -4639,9 +4574,9 @@ packages: - libxkbcommon >=1.13.2,<2.0a0 size: 942571 timestamp: 1787178780572 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-16-2.15.4-hf3af7cc_0.conda - sha256: 9a1685c8d8b8488cd7882a9cd6e673e687211ca763ebbcd1e91b6f594eeadc45 - md5: b7545c57a73a51d72aa6eee5695cf051 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-16-2.15.4-hf3af7cc_1.conda + sha256: d1906463070c21f3215cd5de93164f31c44d5bac6ae9b5d8f5adf42b1e304039 + md5: 046d9eada488e1a1657c80b55c23da52 depends: - __glibc >=2.17,<3.0.a0 - icu >=78.3,<79.0a0 @@ -4655,18 +4590,18 @@ packages: license_family: MIT purls: [] run_exports: {} - size: 569196 - timestamp: 1788635197456 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.15.4-h7df9aa5_0.conda - sha256: 053d85821ce5e36b7ce03faea5930382f6af695df9e2cd68e5d8d273072d1551 - md5: ea192d1ef556bf1d55de4acb8c0f5b65 + size: 569062 + timestamp: 1791465631497 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.15.4-h7df9aa5_1.conda + sha256: 8c32be9e8720bc413d2b4267517459d7d71fad4a5f398a2aa8319b6d7b8a00e6 + md5: 51e5162f95bd6840f6b2b3169125fff5 depends: - __glibc >=2.17,<3.0.a0 - icu >=78.3,<79.0a0 - libgcc >=15 - libiconv >=1.18,<2.0a0 - liblzma >=5.8.3,<6.0a0 - - libxml2-16 2.15.4 hf3af7cc_0 + - libxml2-16 2.15.4 hf3af7cc_1 - libzlib >=1.3.2,<2.0a0 license: MIT license_family: MIT @@ -4675,8 +4610,8 @@ packages: weak: - libxml2 - libxml2-16 >=2.15.4 - size: 47077 - timestamp: 1788635202698 + size: 46570 + timestamp: 1791465635923 - conda: https://conda.anaconda.org/conda-forge/linux-64/libxmlb-0.3.29-he944c4b_0.conda sha256: c14c4c8c2644eb5ca75faa1e24f1852d1b7cfe00ecc2639328d2a0798727ef90 md5: ae0d870aa25d493483fbb6a9668d7169 @@ -4857,29 +4792,29 @@ packages: - nss >=3.118,<4.0a0 size: 2057773 timestamp: 1763485556350 -- conda: https://conda.anaconda.org/conda-forge/linux-64/numpy-2.5.3-py312he827f4e_0.conda - sha256: 5b0e4c7757a2df883c5615a8f64bc4c113c4cd303ae6caa3ada5d397d114ce2a - md5: 5abf073f2cf066f2f3d28229b3cbe94d +- conda: https://conda.anaconda.org/conda-forge/linux-64/numpy-2.5.4-py315h11958a5_0.conda + sha256: a3fb581f1a04a25a211f3a95c57dcde750587ee9c47a32055e857b752f4fab7d + md5: 9826d4c893d56867548d8aa126f6e5ec depends: - python + - __glibc >=2.17,<3.0.a0 - libstdcxx >=15 - libgcc >=15 - - __glibc >=2.17,<3.0.a0 - - python_abi 3.12.* *_cp312 - - libblas >=3.9.0,<4.0a0 + - python_abi 3.15.* *_cp315 - libcblas >=3.9.0,<4.0a0 + - libblas >=3.9.0,<4.0a0 - liblapack >=3.9.0,<4.0a0 constrains: - numpy-base <0a0 license: BSD-3-Clause license_family: BSD purls: - - pkg:pypi/numpy?source=compressed-mapping + - pkg:pypi/numpy?source=hash-mapping run_exports: weak: - numpy >=1.25,<3 - size: 9230861 - timestamp: 1788731992112 + size: 9402941 + timestamp: 1791675319144 - conda: https://conda.anaconda.org/conda-forge/linux-64/ocl-icd-2.3.4-hb03c661_1.conda sha256: 75f3bf733523a338f73d6c276c4a26634877cd970edb558f2769d9fa52b100a9 md5: c2871ba95727fd1382c05db66048b64c @@ -4908,16 +4843,16 @@ packages: run_exports: {} size: 55754 timestamp: 1773844383536 -- conda: https://conda.anaconda.org/conda-forge/linux-64/openexr-3.5.2-h0c14885_0.conda - sha256: c1cc0e8a941f4b2f0965153d6059beb0ca9659de7a72610365d7b18443182955 - md5: 30bfc961cbe9da8727eb6e45f1435741 +- conda: https://conda.anaconda.org/conda-forge/linux-64/openexr-3.5.2-hff7ccaf_1.conda + sha256: ad2707a21d08d11b9c8841f32164576b39890f198e67e20e68d9d6abbfe54cf0 + md5: ee0262969f4b7141e57bf5f5197813b3 depends: - libstdcxx >=15 - libgcc >=15 - __glibc >=2.17,<3.0.a0 - - zstd >=1.5.7,<1.6.0a0 - - imath >=3.2.3,<3.2.4.0a0 - openjph >=0.32.0,<0.33.0a0 + - imath >=3.2.3,<3.2.4.0a0 + - zstd >=1.5.7,<1.6.0a0 - libzlib >=1.3.2,<2.0a0 - libdeflate >=1.25,<1.26.0a0 license: BSD-3-Clause @@ -4926,8 +4861,8 @@ packages: run_exports: weak: - openexr >=3.5.2,<3.6.0a0 - size: 1286798 - timestamp: 1791018481284 + size: 1286828 + timestamp: 1791342990783 - conda: https://conda.anaconda.org/conda-forge/linux-64/openh264-2.6.0-h8c49934_2.conda sha256: 6d54eeb307488f725a8f14af955467249735fa69ffd822763e53784aff05b8a3 md5: 2f0b27ebfcbc15298dd99205e91288ba @@ -5187,121 +5122,86 @@ packages: - pulseaudio-client >=17.0,<17.1.0a0 size: 754544 timestamp: 1790788151618 -- conda: https://conda.anaconda.org/conda-forge/linux-64/pycairo-1.29.1-py312h33cdcd1_2.conda - sha256: 0f30929ea60300ba2253ca7830e9b2d01d95b02a45738d7ee531dd78eabd5d20 - md5: b9a1fe88f42385fd78a66eb1dcbd3779 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pycairo-1.29.2-py315hce0d9b5_0.conda + sha256: dd22668b52e0947a49b53f9f3fac3c2de9504339c9767b29074263f787350500 + md5: 82f5f22d7d49d39fdda8dcf3a1a27d9f depends: - __glibc >=2.17,<3.0.a0 - - cairo >=1.18.4,<2.0a0 - - libexpat >=2.8.1,<3.0a0 + - cairo >=1.18.6,<2.0a0 + - libexpat >=2.8.5,<3.0a0 - libfreetype >=2.14.3 - libfreetype6 >=2.14.3 - libgcc >=15 - libzlib >=1.3.2,<2.0a0 - - python >=3.12,<3.13.0a0 - - python_abi 3.12.* *_cp312 + - python >=3.15.0rc2,<3.16.0a0 + - python_abi 3.15.* *_cp315 license: LGPL-2.1-only OR MPL-1.1 purls: - pkg:pypi/pycairo?source=hash-mapping run_exports: {} - size: 122203 - timestamp: 1788834489971 -- conda: https://conda.anaconda.org/conda-forge/linux-64/pygobject-3.58.0-py312hcb680a2_2.conda - sha256: 8d1d0da0e6dafd34b0c10c0e49a26f0b3574a2b9acc05d51f8e8e2040695b9e1 - md5: 0315ee7db5c2eb683ff37f472f9d5fd1 + size: 120835 + timestamp: 1791296072365 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pygobject-3.58.1-py315h9b33cbc_0.conda + sha256: 88b0b4ffef2cbc68fcd67171ecde7875a6a2a43bc15f9fc4d6df922f2598dfd0 + md5: 68b147a6d6cae6b529209dc610b3c447 depends: - __glibc >=2.17,<3.0.a0 - - cairo >=1.18.4,<2.0a0 - - libexpat >=2.8.1,<3.0a0 + - cairo >=1.18.6,<2.0a0 + - libexpat >=2.8.5,<3.0a0 - libffi >=3.7.0,<3.8.0a0 - libfreetype >=2.14.3 - libfreetype6 >=2.14.3 - libgcc >=15 - libgirepository - - libglib >=2.88.3,<3.0a0 + - libglib >=2.90.0,<3.0a0 - libiconv - libzlib >=1.3.2,<2.0a0 - pycairo - - python >=3.12,<3.13.0a0 - - python_abi 3.12.* *_cp312 + - python >=3.15,<3.16.0a0 + - python_abi 3.15.* *_cp315 license: LGPL-2.1-or-later license_family: LGPL purls: - pkg:pypi/pygobject?source=hash-mapping run_exports: {} - size: 416275 - timestamp: 1788874024161 -- conda: https://conda.anaconda.org/conda-forge/linux-64/pyopengl-accelerate-3.1.10-py312h0ec8046_3.conda - sha256: c0977d852fd057895026bffa95f07b7068a9260776ae3d798930c752e2513e3c - md5: 9874a804fe0257949f2f3001ba441d1a + size: 347842 + timestamp: 1791804187125 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pyopengl-accelerate-3.1.10-py315hea8b678_3.conda + sha256: a170ba0a1cd57c96deaaddaa72e409235efc2d87e45e884012fc314ac62ba1b6 + md5: fe4cde394ac2382804406cd57d5c2fe1 depends: - __glibc >=2.17,<3.0.a0 - libgcc >=15 - numpy >=1.25,<3 - pyopengl 3.1.10 - - python >=3.12,<3.13.0a0 - - python_abi 3.12.* *_cp312 + - python >=3.15.0rc2,<3.16.0a0 + - python_abi 3.15.* *_cp315 license: LicenseRef-pyopengl purls: - pkg:pypi/pyopengl-accelerate?source=compressed-mapping run_exports: {} - size: 335479 - timestamp: 1790011784356 -- conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.12.14-h5f976f7_3_cpython.conda - build_number: 3 - sha256: 14c579b1016da04e4c9f1c5c857272d83ec447317d8c4074a07d59de3cef70ef - md5: 98be3cf76eca2e8871f907a03aed3b84 - depends: - - __glibc >=2.17,<3.0.a0 - - bzip2 >=1.0.8,<2.0a0 - - ld_impl_linux-64 >=2.36.1 - - libexpat >=2.8.1,<3.0a0 - - libffi >=3.7.0,<3.8.0a0 - - libgcc >=15 - - liblzma >=5.8.3,<6.0a0 - - libnsl >=2.0.1,<2.1.0a0 - - libpython 3.12.14 h0c77377_3_cpython - - libsqlite >=3.53.4,<4.0a0 - - libuuid >=2.42.3,<3.0a0 - - libxcrypt >=4.4.38 - - libzlib >=1.3.2,<2.0a0 - - ncurses >=6.6,<7.0a0 - - openssl >=3.5.8,<4.0a0 - - readline >=8.3,<9.0a0 - - tk >=8.6.13,<8.7.0a0 - - tzdata - constrains: - - python_abi 3.12.* *_cp312 - license: Python-2.0 - purls: [] - run_exports: - weak: - - python_abi 3.12.* *_cp312 - noarch: - - python - size: 23044161 - timestamp: 1788392496306 -- conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.14.7-hcd007b5_106_cp314.conda - build_number: 106 - sha256: 4cd05407d6b07d00fd5b6cc78bd2f60ae5e21f777eb26ead82be2e3751c610a1 - md5: 56ee91e118243e46bfc0c41e4030d354 + size: 332698 + timestamp: 1790011767220 +- conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.15.0-h6e52b48_100_cp315.conda + build_number: 100 + sha256: 66b6ac5dd5c64ce34a9c610cacdcc25fe43d7daf29611b6c48036a3c51771374 + md5: 29cb4ef071444f9debb0efdce620e1d6 depends: - __glibc >=2.17,<3.0.a0 - bzip2 >=1.0.8,<2.0a0 - ld_impl_linux-64 >=2.36.1 - - libexpat >=2.8.1,<3.0a0 + - libedit >=3.1.20260512,<3.2.0a0 + - libexpat >=2.8.5,<3.0a0 - libffi >=3.7.0,<3.8.0a0 - libgcc >=15 - liblzma >=5.8.3,<6.0a0 - libmpdec >=4.0.0,<5.0a0 - - libpython 3.14.7 hdc7f604_106_cp314 - libsqlite >=3.53.4,<4.0a0 - - libuuid >=2.42.3,<3.0a0 + - libuuid >=2.42.4,<3.0a0 - libzlib >=1.3.2,<2.0a0 - ncurses >=6.6,<7.0a0 - - openssl >=3.5.8,<4.0a0 - - python_abi 3.14.* *_cp314 - - readline >=8.3,<9.0a0 + - openssl >=3.5.9,<4.0a0 + - python_abi 3.15.* *_cp315 - tk >=8.6.13,<8.7.0a0 - tzdata - zstd >=1.5.7,<1.6.0a0 @@ -5309,12 +5209,12 @@ packages: purls: [] run_exports: weak: - - python_abi 3.14.* *_cp314 + - python_abi 3.15.* *_cp315 noarch: - python - size: 26484459 - timestamp: 1788383955577 - python_site_packages_path: lib/python3.14/site-packages + size: 32711189 + timestamp: 1791573141902 + python_site_packages_path: lib/python3.15/site-packages - conda: https://conda.anaconda.org/conda-forge/linux-64/rav1e-0.8.1-h1fbca29_0.conda sha256: cf550bbc8e5ebedb6dba9ccaead3e07bd1cb86b183644a4c853e06e4b3ad5ac7 md5: d83958768626b3c8471ce032e28afcd3 @@ -5331,42 +5231,27 @@ packages: - rav1e >=0.8.1,<0.9.0a0 size: 5595970 timestamp: 1772540833621 -- conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-hd6e31c0_1.conda - sha256: 01b3fe073a66e321970d09e04b388708f8cbdca5cdfbfcb7c9eeb470ad10383d - md5: 69c01c781e7c8190bbdaf79e3848c5ca - depends: - - __glibc >=2.17,<3.0.a0 - - libgcc >=15 - - ncurses >=6.6,<7.0a0 - license: GPL-3.0-only - license_family: GPL - purls: [] - run_exports: - weak: - - readline >=8.3,<9.0a0 - size: 348899 - timestamp: 1787033801506 -- conda: https://conda.anaconda.org/conda-forge/linux-64/rust-1.98.1-hc89c8c8_2.conda - sha256: f5616bd0732c8ba5b6146d9141d5ad2719546fa35379656bb96cd3ef7449ed5e - md5: 248193fbaef32172d879f03478dc8181 +- conda: https://conda.anaconda.org/conda-forge/linux-64/rust-1.99.0-h846490e_0.conda + sha256: d6a0d77a1b21800340eb1d68346d19a2fef2bd1dd1cc27e1a54e319f53bae1dd + md5: 6cdf529ff6fc509fa5dd37fcf1f09d46 depends: - - __glibc >=2.17,<3.0.a0 + - rust-std-x86_64-unknown-linux-gnu ==1.99.0 h480d76f_0 - gcc_impl_linux-64 + - sysroot_linux-64 >=2.17 + - __glibc >=2.17,<3.0.a0 - libgcc >=15 - libzlib >=1.3.2,<2.0a0 - - rust-std-x86_64-unknown-linux-gnu 1.98.1 h2c6d0dc_2 - - sysroot_linux-64 >=2.17 license: MIT license_family: MIT purls: [] run_exports: strong_constrains: - __glibc >=2.17 - size: 173347196 - timestamp: 1788888331501 -- conda: https://conda.anaconda.org/conda-forge/linux-64/scipy-1.18.1-py312h106c528_1.conda - sha256: d080762a708a0c885814995470d6fb186cec366acb080913584127e94ed031d5 - md5: 73cc9629383ee4e1f2166b314527b324 + size: 202603563 + timestamp: 1791710769330 +- conda: https://conda.anaconda.org/conda-forge/linux-64/scipy-1.18.1-py315heedce22_1.conda + sha256: d8a27d682abd3b8b45727f78506d46ac72f4b7a4a8196ff9cdb77f4bfaa740d9 + md5: 6c846359ac6288bbd9dffc4463cdedd9 depends: - __glibc >=2.17,<3.0.a0 - libblas >=3.9.0,<4.0a0 @@ -5379,15 +5264,15 @@ packages: - numpy <2.8 - numpy >=1.25,<3 - numpy >=2.0.0 - - python >=3.12,<3.13.0a0 - - python_abi 3.12.* *_cp312 + - python >=3.15.0rc2,<3.16.0a0 + - python_abi 3.15.* *_cp315 license: BSD-3-Clause license_family: BSD purls: - pkg:pypi/scipy?source=compressed-mapping run_exports: {} - size: 17276369 - timestamp: 1790022640281 + size: 17433989 + timestamp: 1790022464905 - conda: https://conda.anaconda.org/conda-forge/linux-64/sdl2-2.32.56-h54a6638_0.conda sha256: 987ad072939fdd51c92ea8d3544b286bb240aefda329f9b03a51d9b7e777f9de md5: cdd138897d94dc07d99afe7113a07bec @@ -5470,23 +5355,23 @@ packages: - snappy >=1.2.2,<1.3.0a0 size: 45695 timestamp: 1790063285616 -- conda: https://conda.anaconda.org/conda-forge/linux-64/spirv-tools-2026.3-h7148c6a_1.conda - sha256: 1ff7cdc2e65d3980f977a898d07f4e2b1b6f50dbf7e2fed88b3b4221faf34365 - md5: ac9adda1573683c31a8c58850e911273 +- conda: https://conda.anaconda.org/conda-forge/linux-64/spirv-tools-2026.4-h7148c6a_0.conda + sha256: a24d01b6ebb6050734eb305ed08ba194b40f0d6682244485bb94f9c5f3588095 + md5: cb82bffc815f76d0053330d7cb17ebfc depends: - __glibc >=2.17,<3.0.a0 - libgcc >=15 - libstdcxx >=15 constrains: - - spirv-headers >=1.4.357.0,<1.4.357.1.0a0 + - spirv-headers >=1.4.363.0,<1.4.363.1.0a0 license: Apache-2.0 license_family: APACHE purls: [] run_exports: weak: - spirv-tools >=2026,<2027.0a0 - size: 2380634 - timestamp: 1787525383516 + size: 2413550 + timestamp: 1791365453317 - conda: https://conda.anaconda.org/conda-forge/linux-64/svt-av1-4.2.0-hd2095e1_1.conda sha256: 09c242d480632acca31f2a510c9a9f65bb8cca1e82b73d2d0261ec837fff48be md5: 3bd5f5f3f6f6431b9f19b35ad4a8ed21 @@ -6158,18 +6043,18 @@ packages: run_exports: {} size: 4059 timestamp: 1762351264405 -- conda: https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda - sha256: e1a9e3b1c8fe62dc3932a616c284b5d8cbe3124bbfbedcf4ce5c828cb166ee19 - md5: 9614359868482abba1bd15ce465e3c42 +- conda: https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.1-pyhd8ed1ab_0.conda + sha256: 8f53876d0fb3525235e39635dd55633abe0a4307b1b3ab1415c80a9e0bbdf663 + md5: c0a803d97dd09da7e2c8b0fdfaa5739b depends: - - python >=3.10 + - python >=3.11 license: MIT license_family: MIT purls: - - pkg:pypi/iniconfig?source=hash-mapping + - pkg:pypi/iniconfig?source=compressed-mapping run_exports: {} - size: 13387 - timestamp: 1760831448842 + size: 14485 + timestamp: 1791588553866 - conda: https://conda.anaconda.org/conda-forge/noarch/kernel-headers_linux-64-4.18.0-he073ed8_9.conda sha256: 41557eeadf641de6aeae49486cef30d02a6912d8da98585d687894afd65b356a md5: 86d9cba083cd041bfbf242a01a7a1999 @@ -6298,56 +6183,44 @@ packages: run_exports: {} size: 306724 timestamp: 1782127176429 -- conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.12-9_cp312.conda - build_number: 9 - sha256: ee9c2922e07afc85fc82d5fa82c9ac2c79da3be3283a5e17bf2f2ae38dbd02fb - md5: 4c32076993e6270825441d059ab5c18b - constrains: - - python 3.12.* *_cpython - license: BSD-3-Clause - license_family: BSD - purls: [] - run_exports: {} - size: 6751 - timestamp: 1788302823694 -- conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-9_cp314.conda +- conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.15-9_cp315.conda build_number: 9 - sha256: e7f8e965bbfb98e08feb2365cacdad8bb218fa5b5a0a2752f747287f425f213c - md5: 350d89b50d7de805abf2e63e29dee451 + sha256: 976c4a6db967a62385aa633daaab19a929f986b979a7ad836b8b70e15ca7aefd + md5: b23461631fe2abdbade0cb64babe7344 constrains: - - python 3.14.* *_cp314 + - python 3.15.* *_cp315 license: BSD-3-Clause license_family: BSD purls: [] run_exports: {} - size: 6791 - timestamp: 1788302824440 -- conda: https://conda.anaconda.org/conda-forge/noarch/rust-std-aarch64-apple-darwin-1.98.1-hf6ec828_2.conda - sha256: c88fbaa6dd94dab30d2e7814d369d05f653e5232f04473d65fa2bcdc31207ec4 - md5: 77402d6883efe412b63aa5089c11ae91 + size: 6800 + timestamp: 1788302827589 +- conda: https://conda.anaconda.org/conda-forge/noarch/rust-std-aarch64-apple-darwin-1.99.0-h451e77e_0.conda + sha256: 55285f9306fd7d1703dafb0dd22616eb5b15ba9cc20bb877cbec0cd6f3451e16 + md5: ec457f321188195ebf99cb02b0ddc84f depends: - __unix constrains: - - rust >=1.98.1,<1.98.2.0a0 + - rust >=1.99.0,<1.99.1.0a0 license: MIT license_family: MIT purls: [] run_exports: {} - size: 35527854 - timestamp: 1788886991045 -- conda: https://conda.anaconda.org/conda-forge/noarch/rust-std-x86_64-unknown-linux-gnu-1.98.1-h2c6d0dc_2.conda - sha256: 0b174b85c848fb74994debad1590dd13b2245daadc4b244a3d4e5f044ccd7759 - md5: d26821c482dd4577f7bca11d4f34a460 + size: 40414488 + timestamp: 1791710767605 +- conda: https://conda.anaconda.org/conda-forge/noarch/rust-std-x86_64-unknown-linux-gnu-1.99.0-h480d76f_0.conda + sha256: 3bf979d938914818eefaa0bc6d0d07f518f332b2e3a4e040c78f1f89c187d529 + md5: 891c33341ac583b4c6a0cddfcab83ebb depends: - __unix constrains: - - rust >=1.98.1,<1.98.2.0a0 + - rust >=1.99.0,<1.99.1.0a0 license: MIT license_family: MIT purls: [] run_exports: {} - size: 36854225 - timestamp: 1788888197535 + size: 41124616 + timestamp: 1791710769330 - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-84.0.0-pyh332efcf_0.conda sha256: 9e200ee5f9ff19a4d94e4b51c4856d53dec849f91032f345cf0c6bc3d51a7183 md5: 62ac906f1cd582c6c264c95625cb9d6f @@ -6388,19 +6261,19 @@ packages: - __glibc >=2.28,<3.0.a0 size: 24008591 timestamp: 1765578833462 -- conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda - sha256: 91cafdb64268e43e0e10d30bd1bef5af392e69f00edd34dfaf909f69ab2da6bd - md5: b5325cf06a000c5b14970462ff5e4d58 +- conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.5.0-pyh5ded981_0.conda + sha256: 93b6f517fb7adff00b62be02b798d384a1b34d0e368fdb576fb248bcb3bb9ec7 + md5: 36d6a162326c52d6339c8b606f43932d depends: - - python >=3.10 + - python >=3.11 - python license: MIT license_family: MIT purls: - - pkg:pypi/tomli?source=hash-mapping + - pkg:pypi/tomli?source=compressed-mapping run_exports: {} - size: 21561 - timestamp: 1774492402955 + size: 21687 + timestamp: 1791394576814 - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda sha256: 2d888f90af0686044882c74193ec80a90ec1943145d94a7b1b048958acda1848 md5: c70ad746c22219b9700931707482992c @@ -6528,9 +6401,9 @@ packages: - aws-c-cal >=1.0.0,<1.1.0a0 size: 48028 timestamp: 1790949943085 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/aws-c-common-1.0.2-h74c22ad_0.conda - sha256: 21f98dcb2eef17e6db18f2b5b10da4b083fcc56d570437ca6d4644ffa308988a - md5: 294bb97c0dcfd0f486a9e3a960813fbd +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/aws-c-common-1.0.3-h74c22ad_0.conda + sha256: 5d4d37fd8492699a2b25f16c7ba49059f3bac1b4fe4f0c886a601645c2646b24 + md5: 49175df4eb020bae6ea664e048d8c92c depends: - __osx >=11.0 license: Apache-2.0 @@ -6538,9 +6411,9 @@ packages: purls: [] run_exports: weak: - - aws-c-common >=1.0.2,<1.1.0a0 - size: 250603 - timestamp: 1790800542492 + - aws-c-common >=1.0.3,<1.1.0a0 + size: 250839 + timestamp: 1791411811294 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/aws-c-compression-1.0.0-h50d513f_1.conda sha256: e37b6a98ee0b83eb294a8f5e334e6b30d6690a3e21b4e0a08f8fcf9eb9af5d21 md5: 020e142382ed238e2498f8451b8eaa06 @@ -6861,22 +6734,22 @@ packages: - fribidi >=1.0.17,<2.0a0 size: 63577 timestamp: 1790011838821 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/g-ir-build-tools-1.86.0-py314h0924591_3.conda - sha256: ad62c557b72a4557aaeaa1c36baf7fca12b5bc14f40f4b9b6799a71d8d48c5eb - md5: bae9c98536aa6fa9fd261266a5329727 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/g-ir-build-tools-1.86.0-py315h01a7eab_3.conda + sha256: 55ffb17e0dc5bcabda6806361ecf732e596d9a99006a91df0c8717b264ea8ec4 + md5: e7b0f3d605c801c3683e4daedde77154 depends: - __osx >=11.0 - libglib >=2.88.3,<3.0a0 - pkg-config - - python >=3.14,<3.15.0a0 - - python_abi 3.14.* *_cp314 + - python >=3.15.0rc2,<3.16.0a0 + - python_abi 3.15.* *_cp315 - setuptools license: LGPL-2.0-or-later license_family: LGPL purls: [] run_exports: {} - size: 358825 - timestamp: 1788835014471 + size: 360879 + timestamp: 1788835026425 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/g-ir-host-tools-1.86.0-hab4cfce_3.conda sha256: 4aa47060b5f90f077bea21bf5d2ced37d7a72161ca3ef8c2eb195c5808ead3d8 md5: c0e476fc5b68d5420669afba7f2c6a75 @@ -6958,9 +6831,9 @@ packages: run_exports: {} size: 59259516 timestamp: 1726699336785 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/giflib-5.2.2-hd20048c_1.conda - sha256: be9b4cf253c7d3bf20ccc9ff278ea7ec6f144ebbe8fdc9a131d18c35ff49692f - md5: 93963a54079e27fdb1bf8d289ad592d4 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/giflib-6.1.3-h74c22ad_2.conda + sha256: 36a1605ea258ea805fd5a0f23f07ce3febcf4ac041d1ea99d0b31902b79f4358 + md5: edc562dcbe5872664817e64575aa325f depends: - __osx >=11.0 license: MIT @@ -6968,9 +6841,9 @@ packages: purls: [] run_exports: weak: - - giflib >=5.2.2,<5.3.0a0 - size: 73137 - timestamp: 1784694527697 + - giflib >=6.1.3,<6.2.0a0 + size: 82055 + timestamp: 1788948759889 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/glib-2.90.0-h37000cc_0.conda sha256: c80cdb6c599de22c9779a4b9b9a76923ed27c697c3416aec171413d2cdddb66a md5: bd44f411caf7979901afb57a12aee573 @@ -7029,25 +6902,25 @@ packages: - gmp >=6.3.0,<7.0a0 size: 399307 timestamp: 1786629210135 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/gobject-introspection-1.86.0-py314h9e5fe9d_3.conda - sha256: 759a7425caa302561a72e2b430f9099321939da37c8c1bb216ac2f15ce76d035 - md5: 2b3b25a6d2ba2b3dac8f289cc7425ec8 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/gobject-introspection-1.86.0-py315h6882d7b_3.conda + sha256: 95158e0027a31a20394c1f2bf5dbfc5754e19b5b120071bba2807c3ffe20f9cb + md5: b398a4db096434152e4d1c794538a749 depends: - __osx >=11.0 - cairo >=1.18.4,<2.0a0 - - g-ir-build-tools 1.86.0 py314h0924591_3 + - g-ir-build-tools 1.86.0 py315h01a7eab_3 - g-ir-host-tools 1.86.0 hab4cfce_3 - libffi >=3.7.0,<3.8.0a0 - libgirepository 1.86.0 hccdf2ab_3 - libglib >=2.88.3,<3.0a0 - - python >=3.14,<3.15.0a0 - - python_abi 3.14.* *_cp314 + - python >=3.15.0rc2,<3.16.0a0 + - python_abi 3.15.* *_cp315 license: LGPL-2.0-or-later license_family: LGPL purls: [] run_exports: {} - size: 77056 - timestamp: 1788835080950 + size: 77564 + timestamp: 1788835089714 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/graphite2-1.3.15-h784d473_1.conda sha256: 471f34a187fdb4f2df33e26f2e471b16c239a5b277903f643d9c3a8c9a9f44ec md5: 0c7b78d9ffff4f5a6ca28d346f734d8f @@ -7171,19 +7044,19 @@ packages: - gts >=0.7.6,<0.8.0a0 size: 304331 timestamp: 1686545503242 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/harfbuzz-14.5.1-hce30654_0.conda - sha256: 7c06f1bbfed3689047172cc1c2086d01b7b7d0787c089386d71517be3d96f842 - md5: 3f65ee5ca8d49ad04cd128fa875d41b2 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/harfbuzz-14.6.0-hce30654_1.conda + sha256: cb45eb8a728f0bfe3258f3efc51415d2b7b7d97354fc3bb6de8e6d1a4c922c27 + md5: 12d6c765d260e311a19ba54eadd8a46d depends: - - libharfbuzz-devel 14.5.1 hcda0f7c_0 + - libharfbuzz-devel 14.6.0 hcda0f7c_1 license: MIT license_family: MIT purls: [] run_exports: weak: - - libharfbuzz >=14.5.1 - size: 11056 - timestamp: 1790860634536 + - libharfbuzz >=14.6.0 + size: 11070 + timestamp: 1791336149886 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/hdf5-2.2.0-nompi_h9376f17_101.conda sha256: 358af8f9dc60fa2683fdbc7025b57c46214d17411b116e9aeeb617a8f792b6f6 md5: 5ad5e35146fceba4af18fb0febd1ce34 @@ -7233,9 +7106,9 @@ packages: - icu >=78.3,<79.0a0 size: 14070242 timestamp: 1786545847761 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/imagemagick-7.1.2_31-agpl_h07d2cc2_101.conda - sha256: db5041040563c225e6e2dcf46b08b99710f3fdf8a3d20c13bb9af831504bfeb4 - md5: 36baca07c9ec6c21d9115e20c644eeaa +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/imagemagick-7.1.2_31-agpl_hca089ec_102.conda + sha256: ebd853348edd590c87e76770ce114a73642ddef37c8d1983ddd7574ada3024f2 + md5: 0cf793aafc4ec2baf0041762a9bfee2e depends: - __osx >=11.0 - bzip2 >=1.0.8,<2.0a0 @@ -7244,12 +7117,12 @@ packages: - fonts-conda-ecosystem - fonts-conda-forge - ghostscript - - giflib >=5.2.2,<5.3.0a0 + - giflib >=6.1.3,<6.2.0a0 - graphviz >=14.1.2,<15.0a0 - lcms2 >=2.19.1,<3.0a0 - libcxx >=21 - libglib >=2.90.0,<3.0a0 - - libheif >=1.23.5,<1.24.0a0 + - libheif >=1.23.6,<1.24.0a0 - libjpeg-turbo >=3.2.0,<4.0a0 - libjxl >=0.12.0,<0.13.0a0 - liblzma >=5.8.3,<6.0a0 @@ -7273,8 +7146,8 @@ packages: license_family: AGPL purls: [] run_exports: {} - size: 2014710 - timestamp: 1791026343487 + size: 2015995 + timestamp: 1791295098307 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/imath-3.2.3-h83d81d2_1.conda sha256: ee996315909ab9eff406db69c68edcfbb6bb33e50e286bee50f3f3f946c79bb0 md5: cb384534b653a798e509a9e236e60708 @@ -7364,9 +7237,9 @@ packages: - lerc >=4.2.0,<5.0a0 size: 166477 timestamp: 1785036480092 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libabseil-20260526.0-cxx17_h4c27e2a_2.conda - sha256: d9c62dae9d8f5bebadf72fcf369c00cc353183cb2521f9fffbf4c2f70b58bef9 - md5: 2aa5e7dc7b5d218908effd2a8c70a8a8 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libabseil-20260526.0-cxx17_h4c27e2a_3.conda + sha256: 27859d85276442961b01bcfc488dc85426109330ffb157d3224772a7eaa72a75 + md5: 009ec10961e8426182e9d4c33e31db55 depends: - __osx >=11.0 - libcxx >=19 @@ -7380,8 +7253,8 @@ packages: weak: - libabseil >=20260526.0,<20260527.0a0 - libabseil =*=cxx17* - size: 1277537 - timestamp: 1787217005681 + size: 1277648 + timestamp: 1791681045738 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libadwaita-1.10.0-h27f3ac1_0.conda sha256: 94b2b672cd9d3454479952314547671e21212c1a8119260a46e8c5f5f3bb833c md5: 904dff5b527dbff32ffb56b04bbedce2 @@ -7603,17 +7476,17 @@ packages: - libcurl >=8.22.0,<9.0a0 size: 419925 timestamp: 1788340708264 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-23.1.2-h55c6f16_0.conda - sha256: 087a610bacee7901d259ef72c6f060d795ab26831bd7541ba75ced786a5a63f9 - md5: 01af3f03d850cbab9ae9b86d726a9921 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-23.1.3-h55c6f16_0.conda + sha256: 2d9d31172d7919bd276a8d1be338de567dd5afdd181e8d3d3d8c2d8695e09de2 + md5: 424521e52e65ef7f6cf957c4966e6386 depends: - __osx >=11.0 license: Apache-2.0 WITH LLVM-exception license_family: Apache purls: [] run_exports: {} - size: 579372 - timestamp: 1790163402114 + size: 577501 + timestamp: 1791337239735 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libdav1d7-1.5.4-h74c22ad_4.conda sha256: aaa2ef49ed328fba9520cbf2fdadf46fa7487ce38f7bf969625530eff58161ed md5: 72553390096fa362ae64cdee9618d4ef @@ -7625,20 +7498,20 @@ packages: run_exports: {} size: 367474 timestamp: 1788366635932 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libde265-1.1.1-h3feff0a_0.conda - sha256: 38bdf17d4da3e913ace05dc3890363dc85cb39fcfd3aea746e9ad3270763cf69 - md5: b1d3450f78861e98cf805c3c78eee009 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libde265-1.1.3-hfbe1efa_1.conda + sha256: 789c0028997f73bf3ac74f3ec22c41fff261dcc7620f0027137dd8e34c7f8f46 + md5: 364933e6a66868a98e886a8bb7319cc0 depends: - __osx >=11.0 - - libcxx >=19 + - libcxx >=21 license: LGPL-3.0-or-later license_family: LGPL purls: [] run_exports: weak: - - libde265 >=1.1.1,<1.1.2.0a0 - size: 238127 - timestamp: 1780754972046 + - libde265 >=1.1.3,<1.1.4.0a0 + size: 254779 + timestamp: 1790375096788 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libdeflate-1.25-he7e0567_1.conda sha256: d896f4aa4ce4c590c2838678cb1917356fdb461d2a189991c0280c818c362172 md5: 78650d671cb56909bb3e5c13bce310f9 @@ -7680,9 +7553,9 @@ packages: - libdovi >=3.4.0,<4.0a0 size: 358435 timestamp: 1789725584543 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libedit-3.1.20250104-pl5321h26f1114_1.conda - sha256: 257c926f19e32bdb981fc674c76966049b6fc73f706bae58e9fed8757ad1da70 - md5: 843ef89082f368cb889305084d3b483c +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libedit-3.1.20260512-pl5321h26f1114_0.conda + sha256: 5692a969e49b200c7874d1e869d281753361980b2ecfe9191b571d7e550f8b92 + md5: ef2ab2462744ec6ba3a54c34dcc6a06e depends: - ncurses - __osx >=11.0 @@ -7692,9 +7565,9 @@ packages: purls: [] run_exports: weak: - - libedit >=3.1.20250104,<3.2.0a0 - size: 107742 - timestamp: 1786616721640 + - libedit >=3.1.20260512,<3.2.0a0 + size: 108512 + timestamp: 1791327440683 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libev-4.33-h1a92334_3.conda sha256: c0100064506ae8abb432c5a506d474f10af2cf48c33d62bc221fb28b6d6ff6ac md5: 19e86c8a6a47e92bb2e70ca12e758c5c @@ -7925,9 +7798,9 @@ packages: run_exports: {} size: 192347 timestamp: 1776657964747 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libharfbuzz-14.5.1-hcda0f7c_0.conda - sha256: a84540418ae803bd30ab51ee5d382d002e60d8681c66af892c3589d964a68d91 - md5: 71ff222c70bd3c17b6f22f55f541b1e2 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libharfbuzz-14.6.0-hcda0f7c_1.conda + sha256: 3ddd0668249555af41ef771442d4475a7ac92cce38348f4c1414c81528e3b17e + md5: d6098b2d99d69ec90a0877d709d229b3 depends: - __osx >=11.0 - cairo >=1.18.6,<2.0a0 @@ -7943,11 +7816,11 @@ packages: license_family: MIT purls: [] run_exports: {} - size: 1005082 - timestamp: 1790860612261 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libharfbuzz-devel-14.5.1-hcda0f7c_0.conda - sha256: ffc5b6a969e62c12c63ebc43782ba9bdc192dc9602fc1c10402b472d5056cb32 - md5: 48ab4fbe7c1c19640638a6bf66b5dfa4 + size: 1052738 + timestamp: 1791336128117 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libharfbuzz-devel-14.6.0-hcda0f7c_1.conda + sha256: 4eaab8af98511bda62503a567a9908ed378bef4dc5849a50910ed7679c958cff + md5: 859b12266a2f1c2aab875d95af4a0d1c depends: - __osx >=11.0 - cairo >=1.18.6,<2.0a0 @@ -7958,7 +7831,7 @@ packages: - libfreetype >=2.14.3 - libfreetype6 >=2.14.3 - libglib >=2.90.0,<3.0a0 - - libharfbuzz 14.5.1 hcda0f7c_0 + - libharfbuzz 14.6.0 hcda0f7c_1 - libpng >=1.6.59,<1.7.0a0 - libzlib >=1.3.2,<2.0a0 license: MIT @@ -7966,28 +7839,28 @@ packages: purls: [] run_exports: weak: - - libharfbuzz >=14.5.1 - size: 1551298 - timestamp: 1790860629820 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libheif-1.23.5-gpl_hea44f65_100.conda - sha256: 4974b8a7dd5617de62e5cc4993b73a0bd88e92fb75bc17d2ad3291e240156054 - md5: c64c57470b35399035474590268bef96 + - libharfbuzz >=14.6.0 + size: 1538287 + timestamp: 1791336145002 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libheif-1.23.6-gpl_hea44f65_100.conda + sha256: 120dc98914f6881ae345820dec110d2223835ab22344291aa7b72bc73fdd8ece + md5: 2af5c935e1f68f7660579adfc57c8d20 depends: - libcxx >=21 - __osx >=11.0 - - libavif16 >=1.4.2,<2.0a0 - - x265 >=3.5,<3.6.0a0 + - libde265 >=1.1.3,<1.1.4.0a0 - aom >=3.14.1,<3.15.0a0 + - x265 >=3.5,<3.6.0a0 - libdav1d7 >=1.5.4 - - libde265 >=1.1.1,<1.1.2.0a0 + - libavif16 >=1.4.2,<2.0a0 license: LGPL-3.0-or-later license_family: LGPL purls: [] run_exports: weak: - - libheif >=1.23.5,<1.24.0a0 - size: 763829 - timestamp: 1790155645186 + - libheif >=1.23.6,<1.24.0a0 + size: 760763 + timestamp: 1791239316600 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libhwloc-2.15.0-default_h98d0dc1_1001.conda sha256: 71df89f0fe359b3110de311afcd68f5717e618fc22318dd3dd18187c83203503 md5: 210cf625d9a7a865637ae09813e1eb92 @@ -8462,18 +8335,6 @@ packages: - libpsl >=0.23.1,<0.24.0a0 size: 72673 timestamp: 1786970928205 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libpython-3.14.7-h4311e70_106_cp314.conda - build_number: 106 - sha256: b239028180e1ba2f7daff4053c89e41f7c0f1a3c21bb82f6dcb09384d123ec1f - md5: 4d7a303343eff82e8b92d52c5fd3991a - depends: - - __osx >=11.0 - - libcxx >=20 - license: Python-2.0 - purls: [] - run_exports: {} - size: 1875368 - timestamp: 1788383432953 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libraw-0.22.2-h2d05ff4_0.conda sha256: e3e654527f10346ca39fe628bb6f81dadf946647e5c081f84703ab654ae15a45 md5: c312034f884b32402cd6d97dc230c495 @@ -8513,19 +8374,20 @@ packages: - librsvg >=2.62.4,<3.0a0 size: 3888310 timestamp: 1790276358897 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.53.4-h34a968b_102.conda - sha256: 0343b4f1c1a9f2feb0496bf159d13c5dd894ca750b9016f1282df5a4b82bd80e - md5: 46a1017240580d5c504a40293b2d41c1 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.54.0-hca69786_1.conda + sha256: 35e9265b836367ebf75982051a21cf124d0dd6967da628d9356b8aa433cc6947 + md5: 4219b7c43075bdc152b1db4cd0e46828 depends: - __osx >=11.0 + - icu >=78.3,<79.0a0 - libzlib >=1.3.2,<2.0a0 license: blessing purls: [] run_exports: weak: - - libsqlite >=3.53.4,<4.0a0 - size: 939016 - timestamp: 1791167241399 + - libsqlite >=3.54.0,<4.0a0 + size: 953259 + timestamp: 1791584347161 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libssh2-1.11.1-hbf2880b_1.conda sha256: a056e518c3d2d09fbb1778cea80c0c95338fb24adf1a817bbf90fb484850a304 md5: d957a8eda98c49b073e8dcfd677c127a @@ -8710,9 +8572,9 @@ packages: - libxcb >=1.17.0,<2.0a0 size: 324234 timestamp: 1787885764666 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-16-2.15.4-heb56d2d_0.conda - sha256: 8a26f7c94ab849c1930da36c298f4b82e892382b70ae865cd7274188d9d783e2 - md5: b40633f711a5eb8617917e0e944b9965 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-16-2.15.4-heb56d2d_1.conda + sha256: 1acd4dd49c6f54440dc172ab469d0e2c7c3c02de5b1f21158a233eb585ec3c38 + md5: ef82ce9c5e6a0bb8bc9cb0e6935f18ca depends: - __osx >=11.0 - icu >=78.3,<79.0a0 @@ -8725,17 +8587,17 @@ packages: license_family: MIT purls: [] run_exports: {} - size: 469299 - timestamp: 1788635201153 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-2.15.4-h550178d_0.conda - sha256: 81217fcd4d4abdc69ee63cdb9cdb22e05a20b9ba7a76f596579914a838fe5960 - md5: 4b97a34d06740b578c32deba195daf5a + size: 469239 + timestamp: 1791465646245 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-2.15.4-h550178d_1.conda + sha256: f333b4c6c823ea317aeb0b817520ae3ba2fb16277923d848f5f2aee2049198e5 + md5: 5881b9ab679553078d590db82d7e57b3 depends: - __osx >=11.0 - icu >=78.3,<79.0a0 - libiconv >=1.18,<2.0a0 - liblzma >=5.8.3,<6.0a0 - - libxml2-16 2.15.4 heb56d2d_0 + - libxml2-16 2.15.4 heb56d2d_1 - libzlib >=1.3.2,<2.0a0 license: MIT license_family: MIT @@ -8744,8 +8606,8 @@ packages: weak: - libxml2 - libxml2-16 >=2.15.4 - size: 41375 - timestamp: 1788635206084 + size: 41382 + timestamp: 1791465651969 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxmlb-0.3.29-h10573d7_0.conda sha256: e75db4758af9e6aa29794c4e3b3034a21359b80deb3fd4c06c751123d42c646c md5: d692edd5a675528e3e027de95d5a897d @@ -8791,22 +8653,22 @@ packages: - libzlib >=1.3.2,<2.0a0 size: 47822 timestamp: 1785277049190 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvm-openmp-23.1.2-hdb3d66b_0.conda - sha256: f9aea67117079c00b9663ee0bbca7e5409dbe94625c34e68881ce2c97c3d47c9 - md5: 97fa48b6e1d996689e6d3f822a41f1bf +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvm-openmp-23.1.3-hdb3d66b_0.conda + sha256: b8114cade9167d9f0ec927af9b0552b035777c231742d16a18f66bbb8661a303 + md5: 46400897b481f8ca92d4047d16b0690a depends: - __osx >=11.0 constrains: - intel-openmp <0.0a0 - - openmp 23.1.2|23.1.2.* + - openmp 23.1.3|23.1.3.* license: Apache-2.0 WITH LLVM-exception license_family: APACHE purls: [] run_exports: strong: - - llvm-openmp >=23.1.2 - size: 287714 - timestamp: 1790195505384 + - llvm-openmp >=23.1.3 + size: 287857 + timestamp: 1791367997433 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/lz4-c-1.10.0-hdca3b7d_2.conda sha256: 1887867b393eb3c2b336deaffcf228574821ea1e0dcb9ef7bf6c9f59cec40f03 md5: d47f7f3481c6f2fcc4ed22802f61c6dd @@ -8932,47 +8794,47 @@ packages: - nss >=3.118,<4.0a0 size: 1839904 timestamp: 1763486575227 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/numpy-2.5.3-py314he06036b_0.conda - sha256: 693fa3d8d4303c35250025409d1cbc6d0d6270fd3778303dfe9ea89e29003871 - md5: 37bb35c87a060e9ab1551c5bd987074f +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/numpy-2.5.4-py315h39cfc7d_0.conda + sha256: 43db9a7bbd3fa594a97938e9cc403677dae3fde96a84d873803020f4a7c49f7c + md5: 4ad37db97303242c1cdf6f271c8b7df5 depends: - python - - __osx >=11.0 - libcxx >=21 - - libblas >=3.9.0,<4.0a0 - - liblapack >=3.9.0,<4.0a0 + - __osx >=11.0 - libcblas >=3.9.0,<4.0a0 - - python_abi 3.14.* *_cp314 + - python_abi 3.15.* *_cp315 + - liblapack >=3.9.0,<4.0a0 + - libblas >=3.9.0,<4.0a0 constrains: - numpy-base <0a0 license: BSD-3-Clause license_family: BSD purls: - - pkg:pypi/numpy?source=hash-mapping + - pkg:pypi/numpy?source=compressed-mapping run_exports: weak: - numpy >=1.25,<3 - size: 7273776 - timestamp: 1788732001328 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/openexr-3.5.2-hd7663ec_0.conda - sha256: ff179b23fe334e0a9f17f274a02327855d0d16e87d6c6539d7af2526245c16c1 - md5: 6e8df5ef8e58aaa9648e63e86e5d5ade + size: 7281409 + timestamp: 1791675321895 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/openexr-3.5.2-h1458d73_1.conda + sha256: b0caf56ce1b1a73c6844b35f884d4dfd94b017a0f6bcc2abdaf1877d51f67de1 + md5: 60ce02f01108a52cbf31fe32771516d7 depends: - libcxx >=21 - __osx >=11.0 - - openjph >=0.32.0,<0.33.0a0 - - zstd >=1.5.7,<1.6.0a0 + - libdeflate >=1.25,<1.26.0a0 - libzlib >=1.3.2,<2.0a0 - imath >=3.2.3,<3.2.4.0a0 - - libdeflate >=1.25,<1.26.0a0 + - openjph >=0.32.0,<0.33.0a0 + - zstd >=1.5.7,<1.6.0a0 license: BSD-3-Clause license_family: BSD purls: [] run_exports: weak: - openexr >=3.5.2,<3.6.0a0 - size: 1024844 - timestamp: 1791018539779 + size: 1024865 + timestamp: 1791343043696 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openh264-2.6.0-hf8510ef_2.conda sha256: abbf05ef3f338eb70623400c6ecf3318fe3a71fbf56b1099d7b3fddd13a0a0aa md5: 9cafae3c7258971bbf051eeb0e78a628 @@ -9195,81 +9057,80 @@ packages: - pugixml >=1.15,<1.16.0a0 size: 91283 timestamp: 1736601509593 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pycairo-1.29.1-py314h042460f_2.conda - sha256: 98606a3a1169d725e952ad212e0324c720314f8e002b06031d598ef8f852c931 - md5: bb0db2e3cd37ad22e6c5c907e93430f0 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pycairo-1.29.2-py315ha70f204_0.conda + sha256: b12c2c80da9b1c4cfb74e9e8e03a227e668b57596fa043c738c6f685a8ad51ec + md5: 477205e93f053bea89d1cb18dff93f3c depends: - __osx >=11.0 - - cairo >=1.18.4,<2.0a0 - - libexpat >=2.8.1,<3.0a0 + - cairo >=1.18.6,<2.0a0 + - libexpat >=2.8.5,<3.0a0 - libfreetype >=2.14.3 - libfreetype6 >=2.14.3 - libzlib >=1.3.2,<2.0a0 - - python >=3.14,<3.15.0a0 - - python_abi 3.14.* *_cp314 + - python >=3.15.0rc2,<3.16.0a0 + - python_abi 3.15.* *_cp315 license: LGPL-2.1-only OR MPL-1.1 purls: - pkg:pypi/pycairo?source=hash-mapping run_exports: {} - size: 105400 - timestamp: 1788834497845 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pygobject-3.58.0-py314h098b748_2.conda - sha256: 494e7002b13315fc20abe4d9096aa9243eb447b0a16504919eb0b336594012da - md5: eeee279868c173c445120180aef32047 + size: 105066 + timestamp: 1791296103544 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pygobject-3.58.1-py315ha5599b7_0.conda + sha256: a215de0bbb332f6b0943036aa3a4d8f9ba0b96bbe46301ccc289db59b3a08b9a + md5: d87bcbf8a11747b70624f0c959600029 depends: - __osx >=11.0 - - cairo >=1.18.4,<2.0a0 - - libexpat >=2.8.1,<3.0a0 + - cairo >=1.18.6,<2.0a0 + - libexpat >=2.8.5,<3.0a0 - libffi >=3.7.0,<3.8.0a0 - libfreetype >=2.14.3 - libfreetype6 >=2.14.3 - libgirepository - - libglib >=2.88.3,<3.0a0 + - libglib >=2.90.0,<3.0a0 - libiconv - libzlib >=1.3.2,<2.0a0 - pycairo - - python >=3.14,<3.15.0a0 - - python_abi 3.14.* *_cp314 + - python >=3.15,<3.16.0a0 + - python_abi 3.15.* *_cp315 license: LGPL-2.1-or-later license_family: LGPL purls: - pkg:pypi/pygobject?source=hash-mapping run_exports: {} - size: 320570 - timestamp: 1788874022287 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyopengl-accelerate-3.1.10-py314hdd70a12_3.conda - sha256: 815dc483f8f24078888d66a531c64d1877ed154cd27cc59bd243174b77ee5e36 - md5: 53ba2a5cfbe8a752294ca0a5fd0b255a + size: 320715 + timestamp: 1791804186864 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyopengl-accelerate-3.1.10-py315he30f02c_3.conda + sha256: 007fc25169b2e222831eedffe855ff1e6f8d8757d1e5461321b28668df19bb9d + md5: d18b9757bcf919ed0a2a66a98009436c depends: - __osx >=11.0 - numpy >=1.25,<3 - pyopengl 3.1.10 - - python >=3.14,<3.15.0a0 - - python_abi 3.14.* *_cp314 + - python >=3.15.0rc2,<3.16.0a0 + - python_abi 3.15.* *_cp315 license: LicenseRef-pyopengl purls: - pkg:pypi/pyopengl-accelerate?source=compressed-mapping run_exports: {} - size: 262187 - timestamp: 1790011757926 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.14.7-h0ae1c2c_106_cp314.conda - build_number: 106 - sha256: 5ffc8349ca089ec5ac39ad36a095323ad177ebe05cbfc690c1e135560cfd68b7 - md5: 5fd37c78250b4f57c2bcf9ad2e54beb1 + size: 262543 + timestamp: 1790011770567 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.15.0-h2e4dbab_100_cp315.conda + build_number: 100 + sha256: ec74d4c29cfe3519dde12b40aecd978e5c6665eb1cbc4a88856632e762b37b88 + md5: 90e21f07acd2f463b2cb118dacf1d0b4 depends: - __osx >=11.0 - bzip2 >=1.0.8,<2.0a0 - - libexpat >=2.8.1,<3.0a0 + - libedit >=3.1.20260512,<3.2.0a0 + - libexpat >=2.8.5,<3.0a0 - libffi >=3.7.0,<3.8.0a0 - liblzma >=5.8.3,<6.0a0 - libmpdec >=4.0.0,<5.0a0 - - libpython 3.14.7 h4311e70_106_cp314 - libsqlite >=3.53.4,<4.0a0 - libzlib >=1.3.2,<2.0a0 - ncurses >=6.6,<7.0a0 - - openssl >=3.5.8,<4.0a0 - - python_abi 3.14.* *_cp314 - - readline >=8.3,<9.0a0 + - openssl >=3.5.9,<4.0a0 + - python_abi 3.15.* *_cp315 - tk >=8.6.13,<8.7.0a0 - tzdata - zstd >=1.5.7,<1.6.0a0 @@ -9277,12 +9138,12 @@ packages: purls: [] run_exports: weak: - - python_abi 3.14.* *_cp314 + - python_abi 3.15.* *_cp315 noarch: - python - size: 12223441 - timestamp: 1788383470498 - python_site_packages_path: lib/python3.14/site-packages + size: 16771640 + timestamp: 1791572124595 + python_site_packages_path: lib/python3.15/site-packages - conda: https://conda.anaconda.org/conda-forge/osx-arm64/rav1e-0.8.1-h8246384_0.conda sha256: 925e35b71fe513e0380ecd2fe137e3f4f248bf7ce4bad96946c7c704b7a50d26 md5: 4706a8a71474c692482c3f86c2175454 @@ -9298,31 +9159,17 @@ packages: - rav1e >=0.8.1,<0.9.0a0 size: 886953 timestamp: 1772541394570 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h8b90a29_1.conda - sha256: d6782b430e6dce4fe8c7ac118cd988d5a193eb4a7f60741edfaede58016b6110 - md5: 9347fd56a002e6b58946831d48fe62f6 - depends: - - __osx >=11.0 - - ncurses >=6.6,<7.0a0 - license: GPL-3.0-only - license_family: GPL - purls: [] - run_exports: - weak: - - readline >=8.3,<9.0a0 - size: 314395 - timestamp: 1787033878899 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/rust-1.98.1-h4ff7c5d_2.conda - sha256: ceac23ee119e64ff51f64b743eebabe1ca99c8795e924abaebfba2ccac478d02 - md5: 0ba7e1825eb22abab436c6353f6932c2 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/rust-1.99.0-haf00ee1_0.conda + sha256: 88d5e301ace1d9759662160c269be033c593206b5426ff2710d90ed8e991c8b3 + md5: a97ecbeb3f2023f37e1f66a54c7bfda4 depends: - - rust-std-aarch64-apple-darwin 1.98.1 hf6ec828_2 + - rust-std-aarch64-apple-darwin ==1.99.0 h451e77e_0 license: MIT license_family: MIT purls: [] run_exports: {} - size: 105729132 - timestamp: 1788887063099 + size: 123173460 + timestamp: 1791710767605 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/s2n-1.7.7-h430f396_0.conda sha256: 643f677b74da742fb477a2ccb14745a0d992c9d40f1f04b45decb7a4e8f07a50 md5: 1f66642f01f98d0ab115fc7bd110373f @@ -9337,9 +9184,9 @@ packages: - s2n >=1.7.7,<1.7.8.0a0 size: 278434 timestamp: 1785890070065 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/scipy-1.18.1-py314hd9f3d4e_1.conda - sha256: f22ceb6bb79b37bfa19964920ed4ee1421369ee3a614f556865e27a54bf87ce7 - md5: 647611d868800dc50a82a5b20672c49f +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/scipy-1.18.1-py315he7dc495_1.conda + sha256: 558d7b962dc06c0a3aa43003331f7f9ba131b2b38fea96db1e9cf7b5a1aa7b98 + md5: 1f7ffc42a4161677b766499f536ce0c5 depends: - __osx >=11.0 - libblas >=3.9.0,<4.0a0 @@ -9352,15 +9199,15 @@ packages: - numpy <2.8 - numpy >=1.25,<3 - numpy >=2.0.0 - - python >=3.14,<3.15.0a0 - - python_abi 3.14.* *_cp314 + - python >=3.15.0rc2,<3.16.0a0 + - python_abi 3.15.* *_cp315 license: BSD-3-Clause license_family: BSD purls: - pkg:pypi/scipy?source=compressed-mapping run_exports: {} - size: 14272608 - timestamp: 1790022149285 + size: 14234068 + timestamp: 1790022199614 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/sdl2-2.32.56-h784d473_0.conda sha256: 595db3f62eec1b86aad03ad8c7e4943e78a18e112228c91adf3f3ada4e959a5c md5: 81a4c982e9ac52e620eb810f463de9ad @@ -9421,22 +9268,22 @@ packages: - snappy >=1.2.2,<1.3.0a0 size: 40420 timestamp: 1790063436300 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/spirv-tools-2026.3-hc0b298b_1.conda - sha256: 962119a27ea90e229d8294dab4d21b221b630898a31cbc0c90cfb9f874e6c132 - md5: 92f682d75053c1fe0743ded97193d6e5 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/spirv-tools-2026.4-hc0b298b_0.conda + sha256: 50383f1158ab40bd1e350b3cc3d9ee42537a7808bdd476aebbef6b3bfd333a74 + md5: 0453a9ccfb73047755b6b3dfd3b5dd56 depends: - __osx >=11.0 - libcxx >=21 constrains: - - spirv-headers >=1.4.357.0,<1.4.357.1.0a0 + - spirv-headers >=1.4.363.0,<1.4.363.1.0a0 license: Apache-2.0 license_family: APACHE purls: [] run_exports: weak: - spirv-tools >=2026,<2027.0a0 - size: 1683131 - timestamp: 1787525925536 + size: 1702327 + timestamp: 1791365270797 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/svt-av1-4.2.0-h484c67d_1.conda sha256: 9122d11ed85053b1223ea0645aeac0ca583adc7482e60e3266c37e70d5242195 md5: 253103cc53f925c8a19fd07a000d7b78 @@ -9648,21 +9495,21 @@ packages: requires_dist: - raydriver==0.3.0 - raygeo==1.63.0 - - aiohttp==3.14.3 + - aiohttp==3.14.5 - asyncudp==0.11.0 - blinker==1.9.0 - ezdxf==1.4.4 - gitpython==3.2.0 - - numpy==2.5.3 + - numpy==2.5.4 - opencv-python - - platformdirs==4.12.3 + - platformdirs==4.13.0 - pluggy==1.6.0 - - pycairo==1.29.1 - - pygobject==3.58.0 + - pycairo==1.29.2 + - pygobject==3.58.1 - pymupdf==1.28.2 - pyopengl==3.1.10 - pyopengl-accelerate==3.1.10 - - pypdf==6.19.0 + - pypdf==6.20.0 - pyserial==3.5 - pyvips==3.2.0 - pyyaml==6.0.3 @@ -9694,11 +9541,6 @@ packages: - textual>=8.2,<9 - rpyc>=6.0 requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/03/c0/a6d8be9c43e4456fb9597fdf8b5e0ce1f0a5df41503acce6d869536e4e23/websockets-17.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - name: websockets - version: '17.2' - sha256: 6627b913b8586b1c06db9516b31dd0dfbc621de3bb9312616d92a7e44f268a5b - requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl name: pytest-asyncio version: 1.4.0 @@ -9713,6 +9555,11 @@ packages: - coverage>=6.2 ; extra == 'testing' - hypothesis>=5.7.1 ; extra == 'testing' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz + name: pyyaml + version: 6.0.3 + sha256: d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f + requires_python: '>=3.8' - pypi: https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl name: defusedxml version: 0.7.1 @@ -9724,50 +9571,27 @@ packages: sha256: c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0 requires_dist: - hidapi ; extra == 'cp2110' -- pypi: https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl - name: pycparser - version: '3.0' - sha256: b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 +- pypi: https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl + name: cffi + version: 2.1.1 + sha256: 2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8 + requires_dist: + - pycparser ; implementation_name != 'PyPy' requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl name: blinker version: 1.9.0 sha256: ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/12/83/c745b210ec49379ebfe627e166b527f44671a1f6ec5e1e219d91caa8964d/fonttools-4.66.1-cp314-cp314-macosx_10_15_universal2.whl - name: fonttools - version: 4.66.1 - sha256: b18803cbdef248e7ee1be59cb277fbbe1da1faaa6f726fa5d3557904e6a3d967 +- pypi: https://files.pythonhosted.org/packages/12/ee/2fba0aecb52e7020e189f684148783aa0b9cfa3b3bfb0b400646eef70ad4/yarl-1.25.1-cp315-cp315-macosx_11_0_arm64.whl + name: yarl + version: 1.25.1 + sha256: f074e8d4aa0a5798920ddb6de3d08b228c614ff3724c3e8bd7577f4bafea867b requires_dist: - - lxml>=4.0 ; extra == 'lxml' - - brotli>=1.0.1 ; platform_python_implementation == 'CPython' and extra == 'woff' - - brotlicffi>=0.8.0 ; platform_python_implementation != 'CPython' and extra == 'woff' - - zopfli>=0.1.4 ; extra == 'woff' - - unicodedata2>=18.0.0 ; python_full_version < '3.16' and extra == 'unicode' - - lz4>=1.7.4.2 ; extra == 'graphite' - - scipy ; platform_python_implementation != 'PyPy' and extra == 'interpolatable' - - munkres ; platform_python_implementation == 'PyPy' and extra == 'interpolatable' - - pycairo ; extra == 'interpolatable' - - matplotlib ; extra == 'plot' - - sympy ; extra == 'symfont' - - xattr ; sys_platform == 'darwin' and extra == 'type1' - - skia-pathops>=0.5.0 ; extra == 'pathops' - - uharfbuzz>=0.45.0 ; extra == 'repacker' - - lxml>=4.0 ; extra == 'all' - - brotli>=1.0.1 ; platform_python_implementation == 'CPython' and extra == 'all' - - brotlicffi>=0.8.0 ; platform_python_implementation != 'CPython' and extra == 'all' - - zopfli>=0.1.4 ; extra == 'all' - - unicodedata2>=18.0.0 ; python_full_version < '3.16' and extra == 'all' - - lz4>=1.7.4.2 ; extra == 'all' - - scipy ; platform_python_implementation != 'PyPy' and extra == 'all' - - munkres ; platform_python_implementation == 'PyPy' and extra == 'all' - - pycairo ; extra == 'all' - - matplotlib ; extra == 'all' - - sympy ; extra == 'all' - - xattr ; sys_platform == 'darwin' and extra == 'all' - - skia-pathops>=0.5.0 ; extra == 'all' - - uharfbuzz>=0.45.0 ; extra == 'all' - requires_python: '>=3.11' + - idna>=2.0 + - multidict>=4.0 + - propcache>=0.2.1 + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/13/d4/1152d1c7ab42d8b908be64fd200ddc870dc9d4925e951198702084aa1a7d/linkify_it_py-2.2.0-py3-none-any.whl name: linkify-it-py version: 2.2.0 @@ -9788,89 +9612,22 @@ packages: - sphinx-book-theme ; extra == 'doc' - myst-parser ; extra == 'doc' requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/16/67/1f0b582e73ce46511f2799cbdcdbdf9ca98f344f2aad42ff5ff5dde0d8fe/narwhals-2.27.0-py3-none-any.whl - name: narwhals - version: 2.27.0 - sha256: 9d5661f93a10cfb2b2cc2b3e6e866541dea1b05706a3bcd22f633d70cfae3067 - requires_dist: - - cudf-cu12>=24.10.0 ; sys_platform == 'linux' and extra == 'cudf' - - dask[dataframe]>=2024.8 ; extra == 'dask' - - duckdb>=1.1 ; extra == 'duckdb' - - ibis-framework>=6.0.0 ; extra == 'ibis' - - packaging>=21.3 ; extra == 'ibis' - - pyarrow-hotfix>=0.7 ; extra == 'ibis' - - modin>=0.22.0 ; extra == 'modin' - - pandas>=1.3.4 ; extra == 'pandas' - - polars>=0.20.4 ; extra == 'polars' - - pyarrow>=13.0.0 ; extra == 'pyarrow' - - pyspark>=3.5.0 ; extra == 'pyspark' - - pyspark[connect]>=3.5.0 ; extra == 'pyspark-connect' - - narwhals[duckdb] ; extra == 'sql' - - sqlparse>=0.5.5 ; extra == 'sql' - - sqlframe>=3.22.0,!=3.39.3 ; extra == 'sqlframe' +- pypi: https://files.pythonhosted.org/packages/15/56/50cf2a45c6163edafd737e2fd555103a26ce6748e1e241fb56ed445ea835/msgpack-1.2.3-cp315-cp315-macosx_11_0_arm64.whl + name: msgpack + version: 1.2.3 + sha256: 0955b9000725573d1457c1676944b370dd9643c8d18f25bda5ac72913f850949 requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/16/7d/e9ffbadfbf89c93848412d04594135c4ae8c1d37d9e053b9c3ed718fabc4/python_discovery-1.6.1-py3-none-any.whl - name: python-discovery - version: 1.6.1 - sha256: d43fcdef879fe795352bd13ccf8d185ba5a9f86f36cfcd00529f596e737442b3 - requires_dist: - - filelock>=3.15.4 - requires_python: '>=3.8' - pypi: https://files.pythonhosted.org/packages/17/fe/77f4a24264e728fd95542e53f026a91249b51d1611087b8c82d3a033ea97/asyncudp-0.11.0-py3-none-any.whl name: asyncudp version: 0.11.0 sha256: 96d859d86471e3ed30cf9f05cb38e96920156e3a9fe70ee916096ba758ea316e -- pypi: https://files.pythonhosted.org/packages/22/35/285ce45186a60572472c96df5646989ca97710e060419e5c749226a057cf/raygeo-1.63.0.tar.gz - name: raygeo - version: 1.63.0 - sha256: 6a411c28e7aecea81fa7ccb3d34c652ed1ff4bf1fa677a09323fdd00988a77c8 - requires_dist: - - msgpack - - numpy>=1.20.0 - - pycairo ; extra == 'test' - - pytest ; extra == 'test' - - pytest-mock ; extra == 'test' - - streamlit ; extra == 'test' - - streamlit ; extra == 'visual' - - matplotlib ; extra == 'visual' - - matplotlib ; extra == 'docs' - - mdformat ; extra == 'docs' - - mdformat-frontmatter ; extra == 'docs' - - mdformat-tables ; extra == 'docs' - - pillow ; extra == 'docs' - - matplotlib ; extra == 'cli' - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl - name: mccabe - version: 0.7.0 - sha256: 6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e - requires_python: '>=3.6' -- pypi: https://files.pythonhosted.org/packages/31/c4/e54512d343c4c177e92c45ec16852abe63945b924274c692a5a1867db011/zeroconf-0.151.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - name: zeroconf - version: 0.151.5 - sha256: 2e843fdb7249cbf612cf0bdb729d4581d057b78c45423bd46a6bf377832b4ffc - requires_dist: - - ifaddr>=0.1.7 - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl - name: pyparsing - version: 3.3.3 - sha256: ece8c00a69cf01b45d0b1dedabb469c90d8caf996d4fda40f147627a122849a4 - requires_dist: - - railroad-diagrams ; extra == 'diagrams' - - jinja2 ; extra == 'diagrams' - requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/3a/cf/9c2e4d6c179529d5bf4a64cff76fa581486569e9fbdd35bd98f51cb624bf/msgpack-1.2.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - name: msgpack - version: 1.2.3 - sha256: ede33b2892ceb976283e009ad12fa1834cfdf1f9c43ee9c97849fc588d00a618 - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/3c/2c/c43c03eaf630435f023f1dc61ec4a4a78951ad5530a62c71cc89bde307b7/pypdf-6.19.0-py3-none-any.whl - name: pypdf - version: 6.19.0 - sha256: 7e5d6e730e7dae87d560a2cee218b852f6498c8be61966f3cd02ead971e48d14 +- pypi: https://files.pythonhosted.org/packages/18/42/a945f65cc61c739ec80f4112c4b78ed1791f25d33f45f19389c9c9e247e2/pypdf-6.20.0-py3-none-any.whl + name: pypdf + version: 6.20.0 + sha256: f003fc2014814d264fe7dd3f9d435c158e23e1a85a2233f87a0a2d6d21c914ad requires_dist: - typing-extensions>=4.0 ; python_full_version < '3.11' + - brotli>=1.2.0 ; extra == 'brotli' - cryptography>3.0 ; extra == 'crypto' - pycryptodome ; extra == 'cryptodome' - flit ; extra == 'dev' @@ -9886,6 +9643,7 @@ packages: - sphinx-rtd-theme ; extra == 'docs' - fonttools ; extra == 'fonts' - arabic-reshaper ; extra == 'full' + - brotli>=1.2.0 ; extra == 'full' - cryptography>3.0 ; extra == 'full' - fonttools ; extra == 'full' - pillow>=8.0.0 ; extra == 'full' @@ -9894,6 +9652,70 @@ packages: - arabic-reshaper ; extra == 'rtl-text' - python-bidi ; extra == 'rtl-text' requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/22/35/285ce45186a60572472c96df5646989ca97710e060419e5c749226a057cf/raygeo-1.63.0.tar.gz + name: raygeo + version: 1.63.0 + sha256: 6a411c28e7aecea81fa7ccb3d34c652ed1ff4bf1fa677a09323fdd00988a77c8 + requires_dist: + - msgpack + - numpy>=1.20.0 + - pycairo ; extra == 'test' + - pytest ; extra == 'test' + - pytest-mock ; extra == 'test' + - streamlit ; extra == 'test' + - streamlit ; extra == 'visual' + - matplotlib ; extra == 'visual' + - matplotlib ; extra == 'docs' + - mdformat ; extra == 'docs' + - mdformat-frontmatter ; extra == 'docs' + - mdformat-tables ; extra == 'docs' + - pillow ; extra == 'docs' + - matplotlib ; extra == 'cli' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl + name: mccabe + version: 0.7.0 + sha256: 6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e + requires_python: '>=3.6' +- pypi: https://files.pythonhosted.org/packages/29/5c/969a1b72692055fd2a419590c41847ec9144fefb97b75ff1cde5b6372891/aiohttp-3.14.5-cp315-cp315-macosx_11_0_arm64.whl + name: aiohttp + version: 3.14.5 + sha256: 9b42db919715e91eb76acf3bc492a9a7ccd8bd9adc6745c1412b689735269f14 + requires_dist: + - aiohappyeyeballs>=2.5.0 + - aiosignal>=1.4.0 + - async-timeout>=4.0,<6.0 ; python_full_version < '3.11' + - attrs>=17.3.0 + - frozenlist>=1.1.1 + - multidict>=4.5,<8.0 + - propcache>=0.2.0 + - typing-extensions>=4.4 ; python_full_version < '3.13' + - yarl>=1.25.1,<2.0 + - aiodns>=3.3.0 ; sys_platform != 'android' and sys_platform != 'ios' and extra == 'speedups' + - brotli>=1.2 ; platform_python_implementation == 'CPython' and sys_platform != 'android' and sys_platform != 'ios' and extra == 'speedups' + - brotlicffi>=1.2 ; platform_python_implementation != 'CPython' and extra == 'speedups' + - backports-zstd ; python_full_version < '3.14' and platform_python_implementation == 'CPython' and sys_platform != 'android' and sys_platform != 'ios' and extra == 'speedups' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/2c/3c/4a01195d93420cad1175bedad13a515dc8a56f95a6e39789b92e582682f5/ruff-0.17.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + name: ruff + version: 0.17.0 + sha256: bc73e7c133e82d55b5f15897b2a442d72c0cb4a0c886c46801ce3c247150b60c + requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/31/38/df472520f3e626524d7e2fc9d6da0afe7895a2f1489d36b48af8ca40bb41/coverage-7.16.2-cp315-cp315-macosx_11_0_arm64.whl + name: coverage + version: 7.16.2 + sha256: 00d3eb96e9988c45f50cccd1f1496571ac5c1f91386ac02c4d55516eeda19a24 + requires_dist: + - tomli ; python_full_version <= '3.11' and extra == 'toml' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl + name: pyparsing + version: 3.3.3 + sha256: ece8c00a69cf01b45d0b1dedabb469c90d8caf996d4fda40f147627a122849a4 + requires_dist: + - railroad-diagrams ; extra == 'diagrams' + - jinja2 ; extra == 'diagrams' + requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/3f/99/2e119d541d596daea39643eb9312b47c7847383951300f889166938035b1/rpyc-6.0.2-py3-none-any.whl name: rpyc version: 6.0.2 @@ -9917,6 +9739,18 @@ packages: - flake8 ; extra == 'test' - isort ; extra == 'test' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + name: cffi + version: 2.1.1 + sha256: aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399 + requires_dist: + - pycparser ; implementation_name != 'PyPy' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/44/b0/554d720d71083ccd24ba2f376c048544c073570e7bb18b34d769cef77f66/pyflakes-4.0.3-py2.py3-none-any.whl + name: pyflakes + version: 4.0.3 + sha256: 330ba92b8c1db2eb0b8f4068f6c58674e2649a99e334769aa50e3e9c5b11c23a + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/45/e2/bbb7129c9e7999a6b8ee9cca3b66486c25c423ab5a75f34071798b74ce94/pre_commit-4.6.2-py2.py3-none-any.whl name: pre-commit version: 4.6.2 @@ -9928,35 +9762,19 @@ packages: - pyyaml>=5.1 - virtualenv>=20.10.0 requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/4a/4d/2983a9fc0a412a09151299d1ec9eb7511e3d577f7213e19395fabebf9db6/filelock-4.0.11-py3-none-any.whl - name: filelock - version: 4.0.11 - sha256: 1cbdc4ace7fb11d2f6d073f5c3d91537a23b3d58d2bf2fdffc12c6d717a589c6 - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/4f/a7/aeedb5140afa41fc74c225e9184ab96723a6e873b6ee1c9fede7283456d8/markupsafe-3.0.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - name: markupsafe - version: 3.0.4 - sha256: 8e124f974786f831d6043728e38296969d3579db8896fe004682f5758e613581 - requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - name: aiohttp - version: 3.14.3 - sha256: 543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42 +- pypi: https://files.pythonhosted.org/packages/52/5b/799c3e5b8e04d5fa1c9b61a4c1f5079603e747205c7311b92abd30283df5/virtualenv-21.14.7-py3-none-any.whl + name: virtualenv + version: 21.14.7 + sha256: 3769219a308c5d2f093e7729621ad12a6346b5767a7ec4338414e2a0fb0c526b requires_dist: - - aiohappyeyeballs>=2.5.0 - - aiosignal>=1.4.0 - - async-timeout>=4.0,<6.0 ; python_full_version < '3.11' - - attrs>=17.3.0 - - frozenlist>=1.1.1 - - multidict>=4.5,<7.0 - - propcache>=0.2.0 - - typing-extensions>=4.4 ; python_full_version < '3.13' - - yarl>=1.17.0,<2.0 - - aiodns>=3.3.0 ; sys_platform != 'android' and sys_platform != 'ios' and extra == 'speedups' - - brotli>=1.2 ; platform_python_implementation == 'CPython' and sys_platform != 'android' and sys_platform != 'ios' and extra == 'speedups' - - brotlicffi>=1.2 ; platform_python_implementation != 'CPython' and extra == 'speedups' - - backports-zstd ; python_full_version < '3.14' and platform_python_implementation == 'CPython' and sys_platform != 'android' and sys_platform != 'ios' and extra == 'speedups' - requires_python: '>=3.10' + - distlib>=0.4.3,<1 + - filelock>=3.24.2,<5 ; python_full_version >= '3.10' + - filelock<=3.19.1,>=3.19.1 ; python_full_version < '3.10' + - packaging>=26.3 + - platformdirs>=4.4,<5 + - python-discovery>=1.6.1 + - typing-extensions>=4.16 ; python_full_version < '3.11' + requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/54/c8/12811c9b48fde162bb72b6f2e78fada9a247a09d7bb5be2050a5d099c77b/nodeenv-1.11.0-py2.py3-none-any.whl name: nodeenv version: 1.11.0 @@ -9981,42 +9799,6 @@ packages: - hypothesis>=6.141.1 ; extra == 'all' - coverage>=7.10.0 ; extra == 'all' requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/5c/09/1abf942723c0001d9c2fca1551907dade6304517b982b0bf10bba107fa81/websockets-17.2-cp314-cp314-macosx_11_0_arm64.whl - name: websockets - version: '17.2' - sha256: 06e46da092bca3a52e98f0458c66b247993ce501a07cd09c858be3296511ab7d - requires_python: '>=3.11' -- pypi: https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - name: pillow - version: 12.3.0 - sha256: 251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b - requires_dist: - - furo ; extra == 'docs' - - olefile ; extra == 'docs' - - sphinx>=8.2 ; extra == 'docs' - - sphinx-autobuild ; extra == 'docs' - - sphinx-copybutton ; extra == 'docs' - - sphinx-inline-tabs ; extra == 'docs' - - sphinxext-opengraph ; extra == 'docs' - - olefile ; extra == 'fpx' - - olefile ; extra == 'mic' - - arro3-compute ; extra == 'test-arrow' - - arro3-core ; extra == 'test-arrow' - - nanoarrow ; extra == 'test-arrow' - - pyarrow ; extra == 'test-arrow' - - coverage>=7.4.2 ; extra == 'tests' - - defusedxml ; extra == 'tests' - - markdown2 ; extra == 'tests' - - olefile ; extra == 'tests' - - packaging ; extra == 'tests' - - pytest ; extra == 'tests' - - pytest-cov ; extra == 'tests' - - pytest-timeout ; extra == 'tests' - - pytest-xdist ; extra == 'tests' - - setuptools ; extra == 'tests' - - trove-classifiers>=2024.10.12 ; extra == 'tests' - - defusedxml ; extra == 'xmp' - requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/60/33/df6d7d04854a58619f8349a51e3edb138324130a7562b0bb21f115bb940f/tornado-6.5.10-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl name: tornado version: 6.5.10 @@ -10033,6 +9815,13 @@ packages: - pytest ; extra == 'test' - ruff ; extra == 'test' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/61/3b/50ed32583bfffa737df67d33b9c15d7b25df232c55ab606d059f610ef6ca/python_discovery-1.6.2-py3-none-any.whl + name: python-discovery + version: 1.6.2 + sha256: f0c697f95a3aaec4174a6e5e58f5885e25768684bafc76f1afde384709ebdcf2 + requires_dist: + - filelock>=3.16.1 + requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl name: jinja2 version: 3.1.6 @@ -10046,39 +9835,50 @@ packages: version: 26.1.0 sha256: c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - name: frozenlist - version: 1.8.0 - sha256: 494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383 - requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/6e/bb/dda344765ffd3430afe1a1c66c866a57fae67786537d4f14607df6505ac1/yarl-1.25.1-cp314-cp314-macosx_11_0_arm64.whl - name: yarl - version: 1.25.1 - sha256: 7a5c3115595995779ee21f2567035793911c3802a43c74f3fbb0314929ec67ac +- pypi: https://files.pythonhosted.org/packages/65/a9/55effa83e64b9ff4f379d9186236d50d03f6d4770d8346805c1b6620c370/fonttools-4.66.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + name: fonttools + version: 4.66.1 + sha256: 0dc6fd99cb8c30941036308b148da9432640442a6f26f36d71dad9be24cbd0e9 requires_dist: - - idna>=2.0 - - multidict>=4.0 - - propcache>=0.2.1 - requires_python: '>=3.10' + - lxml>=4.0 ; extra == 'lxml' + - brotli>=1.0.1 ; platform_python_implementation == 'CPython' and extra == 'woff' + - brotlicffi>=0.8.0 ; platform_python_implementation != 'CPython' and extra == 'woff' + - zopfli>=0.1.4 ; extra == 'woff' + - unicodedata2>=18.0.0 ; python_full_version < '3.16' and extra == 'unicode' + - lz4>=1.7.4.2 ; extra == 'graphite' + - scipy ; platform_python_implementation != 'PyPy' and extra == 'interpolatable' + - munkres ; platform_python_implementation == 'PyPy' and extra == 'interpolatable' + - pycairo ; extra == 'interpolatable' + - matplotlib ; extra == 'plot' + - sympy ; extra == 'symfont' + - xattr ; sys_platform == 'darwin' and extra == 'type1' + - skia-pathops>=0.5.0 ; extra == 'pathops' + - uharfbuzz>=0.45.0 ; extra == 'repacker' + - lxml>=4.0 ; extra == 'all' + - brotli>=1.0.1 ; platform_python_implementation == 'CPython' and extra == 'all' + - brotlicffi>=0.8.0 ; platform_python_implementation != 'CPython' and extra == 'all' + - zopfli>=0.1.4 ; extra == 'all' + - unicodedata2>=18.0.0 ; python_full_version < '3.16' and extra == 'all' + - lz4>=1.7.4.2 ; extra == 'all' + - scipy ; platform_python_implementation != 'PyPy' and extra == 'all' + - munkres ; platform_python_implementation == 'PyPy' and extra == 'all' + - pycairo ; extra == 'all' + - matplotlib ; extra == 'all' + - sympy ; extra == 'all' + - xattr ; sys_platform == 'darwin' and extra == 'all' + - skia-pathops>=0.5.0 ; extra == 'all' + - uharfbuzz>=0.45.0 ; extra == 'all' + requires_python: '>=3.11' +- pypi: https://files.pythonhosted.org/packages/66/06/bc7b944f81514378b2c2ab96c17df19e871cd33b9be0f1f6dfc975457e5e/websockets-17.2-cp315-cp315-macosx_11_0_arm64.whl + name: websockets + version: '17.2' + sha256: 3117abfd32b183bdb6194df9317766d32c6517f3d1c0aa8c62d5c6ccfda0b4a8 + requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl name: aiohappyeyeballs version: 2.7.1 sha256: 9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472 requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/72/e2/36f043cdf3970bc9f07cbd0af0263e40681d5a4693e9bb250b29aa425fae/zeroconf-0.151.5-cp314-cp314-macosx_11_0_arm64.whl - name: zeroconf - version: 0.151.5 - sha256: 3cba88b0fb77ea1b4dceaebae577c8245852159c525201b2453cd3bd5715cf56 - requires_dist: - - ifaddr>=0.1.7 - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/75/9e/e3785ba3ecba2bd11efc74bfe2801ca4b78c4480b15a375648d809a59da3/coverage-7.16.2-cp314-cp314-macosx_11_0_arm64.whl - name: coverage - version: 7.16.2 - sha256: a2fac6895eb299a2e52d7bbb8fb3903502b9da8d3f5309ceb16ec40c646b58ee - requires_dist: - - tomli ; python_full_version <= '3.11' and extra == 'toml' - requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/77/c6/040cbc72480d789a5f40d63fb484d3106554c4dfa2d2b70ad5022057750f/webencodings-0.6.1-py3-none-any.whl name: webencodings version: 0.6.1 @@ -10089,6 +9889,11 @@ packages: - pytest ; extra == 'test' - ruff ; extra == 'test' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/80/c9/ddb896767808e3e022453d8dfae26fd52ed404b0aa6fb7f752d39c040208/msgpack-1.2.3-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + name: msgpack + version: 1.2.3 + sha256: 1f4ae8bd4ad9ba085fde95e95d055a896d19210238a4199a771a3cf36dceed49 + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl name: rich version: 15.0.0 @@ -10098,71 +9903,16 @@ packages: - markdown-it-py>=2.2.0 - pygments>=2.13.0,<3.0.0 requires_python: '>=3.9.0' -- pypi: https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - name: pillow - version: 12.3.0 - sha256: 78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91 - requires_dist: - - furo ; extra == 'docs' - - olefile ; extra == 'docs' - - sphinx>=8.2 ; extra == 'docs' - - sphinx-autobuild ; extra == 'docs' - - sphinx-copybutton ; extra == 'docs' - - sphinx-inline-tabs ; extra == 'docs' - - sphinxext-opengraph ; extra == 'docs' - - olefile ; extra == 'fpx' - - olefile ; extra == 'mic' - - arro3-compute ; extra == 'test-arrow' - - arro3-core ; extra == 'test-arrow' - - nanoarrow ; extra == 'test-arrow' - - pyarrow ; extra == 'test-arrow' - - coverage>=7.4.2 ; extra == 'tests' - - defusedxml ; extra == 'tests' - - markdown2 ; extra == 'tests' - - olefile ; extra == 'tests' - - packaging ; extra == 'tests' - - pytest ; extra == 'tests' - - pytest-cov ; extra == 'tests' - - pytest-timeout ; extra == 'tests' - - pytest-xdist ; extra == 'tests' - - setuptools ; extra == 'tests' - - trove-classifiers>=2024.10.12 ; extra == 'tests' - - defusedxml ; extra == 'xmp' - requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/82/96/ef49135ce260db4ca4a12b119ed468449cd248db6b1468e2112b546d7a2e/markupsafe-3.0.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + name: markupsafe + version: 3.0.4 + sha256: 925f929d6b59a8b3f8b8c6ac363cd0af7eecc81efb3071770b3c6717c450a369 + requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/85/11/044d1ae1b4ec0d7af88ee5bc91e081be1022533032b906a7bdabdbb60977/pyproject_hooks-1.3.3-py3-none-any.whl name: pyproject-hooks version: 1.3.3 sha256: 5fc53fdac9f7bd63fbcdc868fb5f90b4784d78a53a3d3388cd738b807441a20b requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/85/9f/83a07b6ec0e043c050cfdd35fb0cf1b7897b91d554d6eea293740309afe7/propcache-0.5.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - name: propcache - version: 0.5.4 - sha256: 2814ecd8e818f487bee4b0f921bc4d1c176cc5fc71ac0f072d0fa67eda4ac14b - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl - name: aiohttp - version: 3.14.3 - sha256: db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b - requires_dist: - - aiohappyeyeballs>=2.5.0 - - aiosignal>=1.4.0 - - async-timeout>=4.0,<6.0 ; python_full_version < '3.11' - - attrs>=17.3.0 - - frozenlist>=1.1.1 - - multidict>=4.5,<7.0 - - propcache>=0.2.0 - - typing-extensions>=4.4 ; python_full_version < '3.13' - - yarl>=1.17.0,<2.0 - - aiodns>=3.3.0 ; sys_platform != 'android' and sys_platform != 'ios' and extra == 'speedups' - - brotli>=1.2 ; platform_python_implementation == 'CPython' and sys_platform != 'android' and sys_platform != 'ios' and extra == 'speedups' - - brotlicffi>=1.2 ; platform_python_implementation != 'CPython' and extra == 'speedups' - - backports-zstd ; python_full_version < '3.14' and platform_python_implementation == 'CPython' and sys_platform != 'android' and sys_platform != 'ios' and extra == 'speedups' - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - name: pyyaml - version: 6.0.3 - sha256: ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc - requires_python: '>=3.8' - pypi: https://files.pythonhosted.org/packages/8c/be/bf49399ad2e788121f595903a56447d4b778d67acbb01ecfc1c6d5cf91f6/pygobject_stubs-2.17.0.tar.gz name: pygobject-stubs version: 2.17.0 @@ -10171,6 +9921,11 @@ packages: - pygobject>=3.55.0 - typing-extensions requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/8d/15/1633010b26e88e872c93b67c0b6c5e174fb74cb6fb5c1472b4d51d4a8f22/platformdirs-4.13.0-py3-none-any.whl + name: platformdirs + version: 4.13.0 + sha256: 3dbcf4cd708f21cf876c4eaa90e58412bc4f033d87143f41b1493ff77c25b7e1 + requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/8f/f6/fb68b8c08f864ecee8f7f7ee2f1203ed5cba2335215de69e4f621eb47db5/trimesh-5.1.1-py3-none-any.whl name: trimesh version: 5.1.1 @@ -10218,6 +9973,11 @@ packages: - dracopy>=1.7.0 ; extra == 'test-more' - trimesh[deprecated,easy,recommend,test,test-more] ; extra == 'all' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/90/11/0e6f11117525ff0eec40ebac3d313376f102df93ca44ad9e893ee85e4f89/pycparser-3.11-py3-none-any.whl + name: pycparser + version: '3.11' + sha256: 51d5a8ba2be0bbe440b99d2112604c95bbbc3c2748a64260186c541e1729cd80 + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/93/cd/11159bcba680ac5f16e48443206d278768f464cc42fe4bfeb169c33db7b6/raydriver-0.3.0.tar.gz name: raydriver version: 0.3.0 @@ -10241,6 +10001,23 @@ packages: - pikepdf ; extra == 'test' - xcffib>=1.4.0 ; extra == 'xcb' requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/96/24/3ec292b44064c16229e064d770b2625bd8ea941aa61f44905a9fa44942c0/markupsafe-3.0.4-cp315-cp315-macosx_11_0_arm64.whl + name: markupsafe + version: 3.0.4 + sha256: ae9dcb8fbe244cb82f8a6458b455b927a03685e383d9bacf1ea5ce180b96dc97 + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/9a/8e/cde07147b6fc56b9ae0fe01d9c0bebd730b2be2c3052d3a5a117e13f561e/multidict-7.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + name: multidict + version: 7.1.0 + sha256: 4b87ad54e8d4adeb0a1f04889504d6ec7f04fb02609220810f51f1b6c66bc1cc + requires_dist: + - typing-extensions>=4.1.0 ; python_full_version < '3.11' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl + name: frozenlist + version: 1.8.0 + sha256: 0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d + requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/9c/1f/19ebc343cc71a7ffa78f17018535adc5cbdd87afb31d7c34874680148b32/ifaddr-0.2.0-py3-none-any.whl name: ifaddr version: 0.2.0 @@ -10265,11 +10042,6 @@ packages: - pytest-xdist ; extra == 'testing' - virtualenv ; extra == 'testing' requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/9f/16/1a5a4a2657effe4806110f2b907313802f1367fcdbb29e8122766e407fab/ruff-0.16.10-py3-none-macosx_11_0_arm64.whl - name: ruff - version: 0.16.10 - sha256: 3031a4a2e8e7b8a46f70be45f198c35a11ece509a94b80334d8d397a33c67550 - requires_python: '>=3.7' - pypi: https://files.pythonhosted.org/packages/a0/09/5ecb6f82d35a2f4a4334d0a608fcf764827fa69dccdf5987ce309c16b6c1/ezdxf-1.4.4-py3-none-any.whl name: ezdxf version: 1.4.4 @@ -10311,16 +10083,11 @@ packages: requires_dist: - smmap>=3.0.1,<6 requires_python: '>=3.7' -- pypi: https://files.pythonhosted.org/packages/a0/71/551608543ee5d590f7e8d522267665d6d9946866ad2a2a70a770f7c70793/msgpack-1.2.3-cp314-cp314-macosx_11_0_arm64.whl - name: msgpack - version: 1.2.3 - sha256: 3372475211a9ce1a23acefe512cb3e121d18c95dc74ed56cb1819ef40836ebf4 +- pypi: https://files.pythonhosted.org/packages/a5/1a/ad561f99f90884089e6403b76c220610809429ba868a81a2e7ce115d32e0/propcache-0.5.4-cp315-cp315-macosx_11_0_arm64.whl + name: propcache + version: 0.5.4 + sha256: 174507f82d3594622acb1dd2dafecf2d899d6d506335494e7107767bf05f3aae requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl - name: frozenlist - version: 1.8.0 - sha256: 4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c - requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl name: mdit-py-plugins version: 0.6.1 @@ -10336,50 +10103,32 @@ packages: - pytest-regressions ; extra == 'testing' - pytest-timeout ; extra == 'testing' requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/a6/6e/babde908b879a3b51ffc230919d06a804912559dd4cf8c94f3fa2af69fec/fonttools-4.66.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - name: fonttools - version: 4.66.1 - sha256: 7b8ff9e0edbcee2fbf7dff0c41b9041c1901c26acf64e23adb67495012df11de - requires_dist: - - lxml>=4.0 ; extra == 'lxml' - - brotli>=1.0.1 ; platform_python_implementation == 'CPython' and extra == 'woff' - - brotlicffi>=0.8.0 ; platform_python_implementation != 'CPython' and extra == 'woff' - - zopfli>=0.1.4 ; extra == 'woff' - - unicodedata2>=18.0.0 ; python_full_version < '3.16' and extra == 'unicode' - - lz4>=1.7.4.2 ; extra == 'graphite' - - scipy ; platform_python_implementation != 'PyPy' and extra == 'interpolatable' - - munkres ; platform_python_implementation == 'PyPy' and extra == 'interpolatable' - - pycairo ; extra == 'interpolatable' - - matplotlib ; extra == 'plot' - - sympy ; extra == 'symfont' - - xattr ; sys_platform == 'darwin' and extra == 'type1' - - skia-pathops>=0.5.0 ; extra == 'pathops' - - uharfbuzz>=0.45.0 ; extra == 'repacker' - - lxml>=4.0 ; extra == 'all' - - brotli>=1.0.1 ; platform_python_implementation == 'CPython' and extra == 'all' - - brotlicffi>=0.8.0 ; platform_python_implementation != 'CPython' and extra == 'all' - - zopfli>=0.1.4 ; extra == 'all' - - unicodedata2>=18.0.0 ; python_full_version < '3.16' and extra == 'all' - - lz4>=1.7.4.2 ; extra == 'all' - - scipy ; platform_python_implementation != 'PyPy' and extra == 'all' - - munkres ; platform_python_implementation == 'PyPy' and extra == 'all' - - pycairo ; extra == 'all' - - matplotlib ; extra == 'all' - - sympy ; extra == 'all' - - xattr ; sys_platform == 'darwin' and extra == 'all' - - skia-pathops>=0.5.0 ; extra == 'all' - - uharfbuzz>=0.45.0 ; extra == 'all' - requires_python: '>=3.11' +- pypi: https://files.pythonhosted.org/packages/a7/20/643f3c8f75594f937b2bf74801241c56a2e2b8e139d24dff8b66b28cdd7f/ruff-0.17.0-py3-none-macosx_11_0_arm64.whl + name: ruff + version: 0.17.0 + sha256: cbf7149e0927dc3295d5d64679a4765576eef71b00782b2ae969ef82274d6bb9 + requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/a8/67/7094dfc74bd9b7263a71a1a5706965750600d7cf740795ce123860986206/zeroconf-0.151.5-cp315-cp315-macosx_11_0_arm64.whl + name: zeroconf + version: 0.151.5 + sha256: 09a933cd3a4bb6a5a0eaf84b58aa0d32af330aa81aa75fc0e300551ccb9b775b + requires_dist: + - ifaddr>=0.1.7 + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/a8/71/9219fe65ef32bddc0b5d14e1d98dfefe9d2c9c1b1d1e93efca1f29fcbd4d/xyzservices-2026.9.1-py3-none-any.whl name: xyzservices version: 2026.9.1 sha256: af4fddac0f1fa5f7951834e2c58e10109e722be548cb1fe4f1a8e8b18cef4c09 requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/ad/40/e2d117b048d47282ade906fbfd92814cbee5647afc13fda88a3406039372/markupsafe-3.0.4-cp314-cp314-macosx_11_0_arm64.whl - name: markupsafe - version: 3.0.4 - sha256: 52704c5d36eb6dda8866493decd61111fff86244c9b1ad225ca01b9e91e5970f - requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/ad/60/8674394ce43f4dadae573a1d6f451716438e9eab7d7fe8d643c673a32d85/yarl-1.25.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + name: yarl + version: 1.25.1 + sha256: 23bf5b403c879a54964e0feac7285688e04bb220074878d737d331522da0a5bf + requires_dist: + - idna>=2.0 + - multidict>=4.0 + - propcache>=0.2.1 + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/ad/9b/9fb3585dabcd73a1b2a6267f63f62649347c9e6d072c9fde365b105abb2c/build-1.6.1-py3-none-any.whl name: build version: 1.6.1 @@ -10394,13 +10143,11 @@ packages: - uv>=0.1.18 ; extra == 'uv' - virtualenv>=20.36.1 ; extra == 'virtualenv' requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - name: cffi - version: 2.1.1 - sha256: c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf - requires_dist: - - pycparser ; implementation_name != 'PyPy' - requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/ae/d4/22cbe288c0d5cef7620503be92c0098d82220353fc7e188034a19c517240/websockets-17.2-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl + name: websockets + version: '17.2' + sha256: 2901bdf24f20bc884124b3e88c61f7ece260c20c81e610f2196007395264a4aa + requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl name: mdurl version: 0.1.2 @@ -10440,13 +10187,6 @@ packages: - pytest-timeout ; extra == 'testing' - requests ; extra == 'testing' requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl - name: cffi - version: 2.1.1 - sha256: 661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72 - requires_dist: - - pycparser ; implementation_name != 'PyPy' - requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/b5/55/fdd01894807d20fb6181f13590ae247585b10e909b42e6ada833379fe520/plumbum-2.0.2-py3-none-any.whl name: plumbum version: 2.0.2 @@ -10464,19 +10204,12 @@ packages: - pycodestyle>=2.15.0,<2.16.0 - pyflakes>=4.0.0,<4.1.0 requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/b6/a8/7df0a3b4f8166c2fb7fe0195f88a0a643cfb7cd1b0430c19f74bd01771f5/pyflakes-4.0.2-py2.py3-none-any.whl - name: pyflakes - version: 4.0.2 - sha256: 01faee1160feadfd9d58c9631a895126ba2032c2e8748e32a96ab51cbd084c6d - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/bc/7b/ca212cbe170ac8b96e45317ecbcf9c3c3ecf0cdec98d5b088a9c4088929b/yarl-1.25.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - name: yarl - version: 1.25.1 - sha256: c6f117789d22dce188e5754e8bc65b7e6ebf8cb73963b9fa761f672a5883769d +- pypi: https://files.pythonhosted.org/packages/b6/83/cf690ab80a0db0f50b300a7141f45dbf1bbbb6b32457f74bb55f47d38f5c/multidict-7.1.0-cp315-cp315-macosx_11_0_arm64.whl + name: multidict + version: 7.1.0 + sha256: c45629c0049fbdef932dbe408ac2b271fdc8c7d9962ca31160f4a0fc3455fe4f requires_dist: - - idna>=2.0 - - multidict>=4.0 - - propcache>=0.2.1 + - typing-extensions>=4.1.0 ; python_full_version < '3.11' requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/bd/59/6b1daa3b94de8970e2a2787ba73616c2d0675d2f948ef4cad8bef7f21bc6/cssselect2-0.10.1-py3-none-any.whl name: cssselect2 @@ -10490,11 +10223,6 @@ packages: - pytest ; extra == 'test' - ruff ; extra == 'test' requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl - name: pyyaml - version: 6.0.3 - sha256: 34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 - requires_python: '>=3.8' - pypi: https://files.pythonhosted.org/packages/be/02/bce7a1d70e9d0f6ea8623c16386138c251b5855af6cd3a11433d3fba4e66/semver-3.1.0-py3-none-any.whl name: semver version: 3.1.0 @@ -10526,46 +10254,17 @@ packages: - selenium~=4.2 ; extra == 'export' - bokeh[export,extra,sampledata] ; extra == 'all' requires_python: '>=3.12' -- pypi: https://files.pythonhosted.org/packages/c5/9b/6ce1ead737fe496611bda600c263b9a11ae7bd8f41bb13f9bd7e0a2c37a4/platformdirs-4.12.3-py3-none-any.whl - name: platformdirs - version: 4.12.3 - sha256: 080f3b39423b5abfca9a23d84c4e9795f54d395cd8459867a8ded44084fcd5f8 - requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/c7/06/dace3e27af26690cb20bead80dbac42941b0841eb689b8aabbd67dde16f0/pymupdf-1.28.2-cp310-abi3-manylinux_2_28_x86_64.whl name: pymupdf version: 1.28.2 sha256: 397d6715c1f0df7548a92d0afd8ce370fc48fa47aeefac16be2bc04a16a8227f requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl - name: pillow - version: 12.3.0 - sha256: e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0 +- pypi: https://files.pythonhosted.org/packages/c7/f0/1d8615d4ac47002b75072b0f4cb2101a7608ee9f6f94f1bd62717c1d7452/zeroconf-0.151.5-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + name: zeroconf + version: 0.151.5 + sha256: 720a872df2924faedd33c737c2b8a4ac43c045b0b278c58d8b32e1eadff4fa9f requires_dist: - - furo ; extra == 'docs' - - olefile ; extra == 'docs' - - sphinx>=8.2 ; extra == 'docs' - - sphinx-autobuild ; extra == 'docs' - - sphinx-copybutton ; extra == 'docs' - - sphinx-inline-tabs ; extra == 'docs' - - sphinxext-opengraph ; extra == 'docs' - - olefile ; extra == 'fpx' - - olefile ; extra == 'mic' - - arro3-compute ; extra == 'test-arrow' - - arro3-core ; extra == 'test-arrow' - - nanoarrow ; extra == 'test-arrow' - - pyarrow ; extra == 'test-arrow' - - coverage>=7.4.2 ; extra == 'tests' - - defusedxml ; extra == 'tests' - - markdown2 ; extra == 'tests' - - olefile ; extra == 'tests' - - packaging ; extra == 'tests' - - pytest ; extra == 'tests' - - pytest-cov ; extra == 'tests' - - pytest-timeout ; extra == 'tests' - - pytest-xdist ; extra == 'tests' - - setuptools ; extra == 'tests' - - trove-classifiers>=2024.10.12 ; extra == 'tests' - - defusedxml ; extra == 'xmp' + - ifaddr>=0.1.7 requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/c8/c3/0bebaaec98aa7cb43e04a8a6079b4aecb83338f6b4b3a32eb837c4c484ea/raydriver-0.3.0-cp311-abi3-macosx_11_0_arm64.whl name: raydriver @@ -10576,16 +10275,16 @@ packages: - pytest-asyncio ; extra == 'test' - pytest-timeout ; extra == 'test' requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/ca/c6/22c009b147de23fb8d1b18587ec303ab99fe4af423802185a2991a0a5bc4/vtracer-0.6.15-cp314-cp314-macosx_11_0_arm64.whl - name: vtracer - version: 0.6.15 - sha256: a845d023d5704eb4edf017cb4e57cad2c7dec36d80160b7f6eab744ca8121e68 - requires_python: '>=3.7' - pypi: https://files.pythonhosted.org/packages/cd/5b/ff5fc58fa2427c30dea74c90053f4fc5eda1e7f3833ed3ecc7147fe2b311/tornado-6.5.10-cp39-abi3-macosx_10_9_universal2.whl name: tornado version: 6.5.10 sha256: 9261783640e23258694a9ff0795df430a5a7b0a651d3dd53dd0969ad6be16da7 requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/ce/4e/58b9b1460afc97a4c0b17ee89af701c4011d4d7f46470eba3aaff76a8069/propcache-0.5.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + name: propcache + version: 0.5.4 + sha256: 23278f808cd81d5ada7184a76606b925fb3389c60e1077b2cd7da7b1fcf0553c + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/cf/4c/c73f828fdbcd37eaf21d08fa852544a3ca7c2dbb3ea76873d64f2ea413d1/opencv_python-5.0.0.93-cp37-abi3-manylinux_2_28_x86_64.whl name: opencv-python version: 5.0.0.93 @@ -10594,13 +10293,51 @@ packages: - numpy<2.0 ; python_full_version < '3.9' - numpy>=2 ; python_full_version >= '3.9' requires_python: '>=3.6' -- pypi: https://files.pythonhosted.org/packages/d1/36/6c45f15be4eca4ac1062c6a55a323286494c99726a7e58951fe85967ac08/coverage-7.16.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - name: coverage - version: 7.16.2 - sha256: 5375ebd99038021b35e99dc88255022912c06565d316212f4a576e4b08d30f5d +- pypi: https://files.pythonhosted.org/packages/cf/f5/2fcc5e30053a938286f17d0edf3f0850b8061b984256fa7c26850b9c8809/aiohttp-3.14.5-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + name: aiohttp + version: 3.14.5 + sha256: 276a4fc00b1d9ae492b802763a789c5b86328b989c5ea169f2faa447d6a11c7c requires_dist: - - tomli ; python_full_version <= '3.11' and extra == 'toml' + - aiohappyeyeballs>=2.5.0 + - aiosignal>=1.4.0 + - async-timeout>=4.0,<6.0 ; python_full_version < '3.11' + - attrs>=17.3.0 + - frozenlist>=1.1.1 + - multidict>=4.5,<8.0 + - propcache>=0.2.0 + - typing-extensions>=4.4 ; python_full_version < '3.13' + - yarl>=1.25.1,<2.0 + - aiodns>=3.3.0 ; sys_platform != 'android' and sys_platform != 'ios' and extra == 'speedups' + - brotli>=1.2 ; platform_python_implementation == 'CPython' and sys_platform != 'android' and sys_platform != 'ios' and extra == 'speedups' + - brotlicffi>=1.2 ; platform_python_implementation != 'CPython' and extra == 'speedups' + - backports-zstd ; python_full_version < '3.14' and platform_python_implementation == 'CPython' and sys_platform != 'android' and sys_platform != 'ios' and extra == 'speedups' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/d1/89/5d4c86da1130d9059681e5b6cd7645df5c10279a6a079c5c37dcb2cc6f3f/narwhals-2.27.1-py3-none-any.whl + name: narwhals + version: 2.27.1 + sha256: d057df13f5852b8e157596e82eb5e955fad267425df5e420e0ee9863da483b31 + requires_dist: + - cudf-cu12>=24.10.0 ; sys_platform == 'linux' and extra == 'cudf' + - dask[dataframe]>=2024.8 ; extra == 'dask' + - duckdb>=1.1 ; extra == 'duckdb' + - ibis-framework>=6.0.0 ; extra == 'ibis' + - packaging>=21.3 ; extra == 'ibis' + - pyarrow-hotfix>=0.7 ; extra == 'ibis' + - modin>=0.22.0 ; extra == 'modin' + - pandas>=1.3.4 ; extra == 'pandas' + - polars>=0.20.4 ; extra == 'polars' + - pyarrow>=13.0.0 ; extra == 'pyarrow' + - pyspark>=3.5.0 ; extra == 'pyspark' + - pyspark[connect]>=3.5.0 ; extra == 'pyspark-connect' + - narwhals[duckdb] ; extra == 'sql' + - sqlparse>=0.5.5 ; extra == 'sql' + - sqlframe>=3.22.0,!=3.39.3 ; extra == 'sqlframe' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/d7/8b/f837f52905395ba4510fe61f753c24833fb0a9c76e21267bb9f828b664a9/filelock-4.1.1-py3-none-any.whl + name: filelock + version: 4.1.1 + sha256: 3f4a557945a7b0f95efeb1f432267affe5d45ac8ddde2aed1b97ebb62382c089 + requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/d7/ba/18b6e682ead424ad24bcc134339ae5d1b931cd9ae260540592a058a91279/pyright-1.1.414-py3-none-any.whl name: pyright version: 1.1.414 @@ -10628,19 +10365,44 @@ packages: - pytest-asyncio ; extra == 'dev' - tox ; extra == 'dev' requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/df/93/084f10ebbecdedf7902ed68fbecc903f0e9c5742155bfffb05eccbc071da/virtualenv-21.14.5-py3-none-any.whl - name: virtualenv - version: 21.14.5 - sha256: b0651e0174982bba17cc6f0aaa2ef496730cb85a3f35eb5d1119d1c904f7d1da +- pypi: https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl + name: pillow + version: 12.3.0 + sha256: 71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701 requires_dist: - - distlib>=0.3.7,<1 - - filelock>=3.24.2,<5 ; python_full_version >= '3.10' - - filelock>=3.16.1,<=3.19.1 ; python_full_version < '3.10' - - packaging>=23.1 - - platformdirs>=3.9.1,<5 - - python-discovery>=1.6 - - typing-extensions>=4.13.2 ; python_full_version < '3.11' - requires_python: '>=3.9' + - furo ; extra == 'docs' + - olefile ; extra == 'docs' + - sphinx>=8.2 ; extra == 'docs' + - sphinx-autobuild ; extra == 'docs' + - sphinx-copybutton ; extra == 'docs' + - sphinx-inline-tabs ; extra == 'docs' + - sphinxext-opengraph ; extra == 'docs' + - olefile ; extra == 'fpx' + - olefile ; extra == 'mic' + - arro3-compute ; extra == 'test-arrow' + - arro3-core ; extra == 'test-arrow' + - nanoarrow ; extra == 'test-arrow' + - pyarrow ; extra == 'test-arrow' + - coverage>=7.4.2 ; extra == 'tests' + - defusedxml ; extra == 'tests' + - markdown2 ; extra == 'tests' + - olefile ; extra == 'tests' + - packaging ; extra == 'tests' + - pytest ; extra == 'tests' + - pytest-cov ; extra == 'tests' + - pytest-timeout ; extra == 'tests' + - pytest-xdist ; extra == 'tests' + - setuptools ; extra == 'tests' + - trove-classifiers>=2024.10.12 ; extra == 'tests' + - defusedxml ; extra == 'xmp' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/de/29/48fca82a7ebf7ff7b2e35019cc9537e7f65e4d2aa1215cc5a8792c989251/coverage-7.16.2-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl + name: coverage + version: 7.16.2 + sha256: 8fc15cc8d0d06e873c00ef18e1372d605f9aaf3de27d8c24e50782e75bc8b843 + requires_dist: + - tomli ; python_full_version <= '3.11' and extra == 'toml' + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/df/f3/90993aab504fa2e1f28fcc09aa16b6ea4f00e75a037d9136e737855833e2/pyvips-3.2.0.tar.gz name: pyvips version: 3.2.0 @@ -10655,63 +10417,75 @@ packages: - sphinx ; extra == 'doc' - sphinx-rtd-theme ; extra == 'doc' requires_python: '>=3.7' -- pypi: https://files.pythonhosted.org/packages/e2/22/b533b493d7025456f44518b33e53e000021a20fe7c27b88cf3d341df7186/propcache-0.5.4-cp314-cp314-macosx_11_0_arm64.whl - name: propcache - version: 0.5.4 - sha256: 1d759d05634f1b038fb625a66662a8c85e5a8fec912da381b5149ddac107482b - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/e4/03/17234145f302a645a123e8c3bb2411ecf4669fbc350de3b0d803230a1729/ruff-0.16.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - name: ruff - version: 0.16.10 - sha256: f33f43a864a8483eebd160e713336c8bab02c934feaff0a33cf5ccb41546d09a +- pypi: https://files.pythonhosted.org/packages/e4/62/c3c23511ebe3d76dc679db0bb045dfd20ed3452bec12dcbbe8d655498a7d/vtracer-0.6.15.tar.gz + name: vtracer + version: 0.6.15 + sha256: f7f39943d1fc8e9dc82f5360a40e941aaf4c9166d8df30c3f6a2da8cc21dfc52 requires_python: '>=3.7' -- pypi: https://files.pythonhosted.org/packages/e7/29/2094fd7719146ca7bb236be9751b5929d431f643cbab669aed89cf121883/ezdxf-1.4.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - name: ezdxf - version: 1.4.4 - sha256: 44af248af0860fe8b1a39ca40e1cd2fa8ac37b22142b14b20c6a0caf1b50f932 - requires_dist: - - pyparsing>=3.0.0 - - typing-extensions>=4.6.0 - - numpy - - fonttools - - pyside6 ; extra == 'draw' - - matplotlib ; extra == 'draw' - - pymupdf>=1.20.0 ; extra == 'draw' - - pillow ; extra == 'draw' - - pyqt5 ; extra == 'draw5' - - matplotlib ; extra == 'draw5' - - pymupdf>=1.20.0 ; extra == 'draw5' - - pillow ; extra == 'draw5' - - pyside6 ; extra == 'dev' - - setuptools ; extra == 'dev' - - wheel ; extra == 'dev' - - cython ; extra == 'dev' - - pytest ; extra == 'dev' - - pillow ; extra == 'dev' - - matplotlib ; extra == 'dev' - - pymupdf>=1.20.0 ; extra == 'dev' - - pyqt5 ; extra == 'dev5' - - setuptools ; extra == 'dev5' - - wheel ; extra == 'dev5' - - cython ; extra == 'dev5' - - pytest ; extra == 'dev5' - - pillow ; extra == 'dev5' - - matplotlib ; extra == 'dev5' - - pymupdf>=1.20.0 ; extra == 'dev5' - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/ed/5b/db08419c1e1f7c9d60cfd2787b2b517d7ae4ebbda8281b48a33eb5141467/multidict-6.9.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - name: multidict - version: 6.9.1 - sha256: 976fd7689d69ec78d67d31d38d396d8adb562f7e8368279f76aed4aa451fa06d +- pypi: https://files.pythonhosted.org/packages/eb/95/235679d5fe4265c251418cd02321de069281a700415389e14c4cce442e3d/fonttools-4.66.1-cp315-cp315-macosx_10_15_universal2.whl + name: fonttools + version: 4.66.1 + sha256: 2d637468dac23aac0e223bd52e66f8faa3b0dfcef57435460fa2107e830226cd requires_dist: - - typing-extensions>=4.1.0 ; python_full_version < '3.11' - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/f9/b4/8e6d950f02ca6ecc00ecf671f2ddb5dab7017671a8d197326f75621f5786/multidict-6.9.1-cp314-cp314-macosx_11_0_arm64.whl - name: multidict - version: 6.9.1 - sha256: 854fd2f1bc6e8a56b89910b5cd7261a8b40f13ebb31572da985ce59c7da0886d + - lxml>=4.0 ; extra == 'lxml' + - brotli>=1.0.1 ; platform_python_implementation == 'CPython' and extra == 'woff' + - brotlicffi>=0.8.0 ; platform_python_implementation != 'CPython' and extra == 'woff' + - zopfli>=0.1.4 ; extra == 'woff' + - unicodedata2>=18.0.0 ; python_full_version < '3.16' and extra == 'unicode' + - lz4>=1.7.4.2 ; extra == 'graphite' + - scipy ; platform_python_implementation != 'PyPy' and extra == 'interpolatable' + - munkres ; platform_python_implementation == 'PyPy' and extra == 'interpolatable' + - pycairo ; extra == 'interpolatable' + - matplotlib ; extra == 'plot' + - sympy ; extra == 'symfont' + - xattr ; sys_platform == 'darwin' and extra == 'type1' + - skia-pathops>=0.5.0 ; extra == 'pathops' + - uharfbuzz>=0.45.0 ; extra == 'repacker' + - lxml>=4.0 ; extra == 'all' + - brotli>=1.0.1 ; platform_python_implementation == 'CPython' and extra == 'all' + - brotlicffi>=0.8.0 ; platform_python_implementation != 'CPython' and extra == 'all' + - zopfli>=0.1.4 ; extra == 'all' + - unicodedata2>=18.0.0 ; python_full_version < '3.16' and extra == 'all' + - lz4>=1.7.4.2 ; extra == 'all' + - scipy ; platform_python_implementation != 'PyPy' and extra == 'all' + - munkres ; platform_python_implementation == 'PyPy' and extra == 'all' + - pycairo ; extra == 'all' + - matplotlib ; extra == 'all' + - sympy ; extra == 'all' + - xattr ; sys_platform == 'darwin' and extra == 'all' + - skia-pathops>=0.5.0 ; extra == 'all' + - uharfbuzz>=0.45.0 ; extra == 'all' + requires_python: '>=3.11' +- pypi: https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl + name: pillow + version: 12.3.0 + sha256: 6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4 requires_dist: - - typing-extensions>=4.1.0 ; python_full_version < '3.11' + - furo ; extra == 'docs' + - olefile ; extra == 'docs' + - sphinx>=8.2 ; extra == 'docs' + - sphinx-autobuild ; extra == 'docs' + - sphinx-copybutton ; extra == 'docs' + - sphinx-inline-tabs ; extra == 'docs' + - sphinxext-opengraph ; extra == 'docs' + - olefile ; extra == 'fpx' + - olefile ; extra == 'mic' + - arro3-compute ; extra == 'test-arrow' + - arro3-core ; extra == 'test-arrow' + - nanoarrow ; extra == 'test-arrow' + - pyarrow ; extra == 'test-arrow' + - coverage>=7.4.2 ; extra == 'tests' + - defusedxml ; extra == 'tests' + - markdown2 ; extra == 'tests' + - olefile ; extra == 'tests' + - packaging ; extra == 'tests' + - pytest ; extra == 'tests' + - pytest-cov ; extra == 'tests' + - pytest-timeout ; extra == 'tests' + - pytest-xdist ; extra == 'tests' + - setuptools ; extra == 'tests' + - trove-classifiers>=2024.10.12 ; extra == 'tests' + - defusedxml ; extra == 'xmp' requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/fa/01/3591f781b417b382a8487a2356e927acfe858b1043bab0ec47f6805bb109/pymupdf-1.28.2-cp310-abi3-macosx_11_0_arm64.whl name: pymupdf @@ -10801,8 +10575,3 @@ packages: - streamlit ; extra == 'visual' - matplotlib ; extra == 'visual' requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/ff/72/b12534f2d335448bc98504610c8d57520e530dab840251871a72cbb6c227/vtracer-0.6.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - name: vtracer - version: 0.6.15 - sha256: cd19743a8dc3a67e1284f5f227c1483197ec3988687c53b5a1b3c52912db2a9e - requires_python: '>=3.7' diff --git a/pixi.toml b/pixi.toml index 750d0a29c..a03873ef6 100644 --- a/pixi.toml +++ b/pixi.toml @@ -19,22 +19,22 @@ expat = "==2.8.5" gettext = "==0.25.1" gobject-introspection = "==1.86.0" gtk4 = "==4.24.1" -libheif = "==1.23.5" +libheif = "==1.23.6" libvips = "==8.18.7" openslide = "==4.0.1" poppler = "==26.7.0" -python = "==3.12.14|==3.14.7|==3.12.14|==3.14.7" +python = "==3.15.0" librsvg = "==2.62.4" svgelements = "==1.9.6" pluggy = "==1.6.0" scipy = "==1.18.1" -numpy = "==2.5.3" -pycairo = "==1.29.1" +numpy = "==2.5.4" +pycairo = "==1.29.2" PyOpenGL = "==3.1.10" PyOpenGL-accelerate = "==3.1.10" -PyGObject = "==3.58.0" +PyGObject = "==3.58.1" pkg-config = "==0.29.2" -rust = "==1.98.1" +rust = "==1.99.0" patchelf = "==0.19.2" [feature.app.pypi-dependencies] @@ -42,15 +42,15 @@ raygeo = "==1.63.0" raydriver = "==0.3.0" rayforge = { editable = true, path = "." } ruida-pa = "==0.22.0" -aiohttp = "==3.14.3" +aiohttp = "==3.14.5" asyncudp = "==0.11.0" blinker = "==1.9.0" build = "==1.6.1" cairosvg = "==2.9.1" ezdxf = "==1.4.4" GitPython = "==3.2.0" -platformdirs = "==4.12.3" -pypdf = "==6.19.0" +platformdirs = "==4.13.0" +pypdf = "==6.20.0" pymupdf = "==1.28.2" pyserial = "==3.5" pyvips = "==3.2.0" @@ -91,16 +91,16 @@ pytest-mock = "==3.16.0" pytest-cov = "==7.1.0" pygobject-stubs = "==2.17.0" flake8 = "==7.4.1" -pyflakes = "==4.0.2" +pyflakes = "==4.0.3" pyright = "==1.1.414" -ruff = "==0.16.10" +ruff = "==0.17.0" pre-commit = "==4.6.2" [feature.website.dependencies] nodejs = "==26.10.0" [feature.video-tools.dependencies] ffmpeg = "==9.0.2" -python = "==3.14.7" +python = "==3.15.0" [feature.video-tools.pypi-dependencies] pillow = "==12.3.0" diff --git a/requirements.txt b/requirements.txt index db6e926a0..896b8d63b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,20 +1,20 @@ raydriver==0.3.0 raygeo==1.63.0 -aiohttp==3.14.3 +aiohttp==3.14.5 asyncudp==0.11.0 blinker==1.9.0 ezdxf==1.4.4 GitPython==3.2.0 -numpy==2.5.3 +numpy==2.5.4 opencv_python -platformdirs==4.12.3 +platformdirs==4.13.0 pluggy==1.6.0 -pycairo==1.29.1 -PyGObject==3.58.0 +pycairo==1.29.2 +PyGObject==3.58.1 PyMuPDF==1.28.2 PyOpenGL==3.1.10 PyOpenGL_accelerate==3.1.10 -pypdf==6.19.0 +pypdf==6.20.0 pyserial==3.5 pyvips==3.2.0 PyYAML==6.0.3 From 170adb7a4e1cd8084bccdd879f98b564d50253bb Mon Sep 17 00:00:00 2001 From: Samuel Abels Date: Mon, 12 Oct 2026 16:49:04 +0200 Subject: [PATCH 053/104] ci: fix pixi dependency diff to include explicit deps Generate the PR body after the upgrade by diffing the committed pixi.lock against the final one with pixi-diff. The previous pixi update --json ran before the upgrade and could not report changes to dependencies pinned with ==, leaving the explicit table empty. --- .github/workflows/pixi-dependabot.yml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pixi-dependabot.yml b/.github/workflows/pixi-dependabot.yml index f0b31c594..6a9c50af2 100644 --- a/.github/workflows/pixi-dependabot.yml +++ b/.github/workflows/pixi-dependabot.yml @@ -22,11 +22,6 @@ jobs: pixi-version: v0.76.2 run-install: false - - name: Generate dependency diff - run: | - set -o pipefail - pixi update --json | pixi exec pixi-diff-to-markdown >> diff.md - - name: Upgrade dependencies run: | : > requirements.txt @@ -37,6 +32,13 @@ jobs: pixi clean cache -y pixi update + - name: Generate dependency diff + run: | + set -o pipefail + git show HEAD:pixi.lock \ + | pixi exec pixi-diff --after pixi.lock --manifest-path pixi.toml \ + | pixi exec pixi-diff-to-markdown > diff.md + - name: Create pull request uses: peter-evans/create-pull-request@v8 with: From d376651a781c0a723dd6f973a40dca2f43df292a Mon Sep 17 00:00:00 2001 From: switch87 <8284204+switch87@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:35:41 +0200 Subject: [PATCH 054/104] feat: import SVGZ (Inkscape's compressed SVG) Refs #546 --- rayforge/image/svg/importer.py | 26 ++++++++++++-- tests/image/svg/test_svgz.py | 65 ++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 tests/image/svg/test_svgz.py diff --git a/rayforge/image/svg/importer.py b/rayforge/image/svg/importer.py index 85e631cf4..f1f3966cd 100644 --- a/rayforge/image/svg/importer.py +++ b/rayforge/image/svg/importer.py @@ -1,4 +1,7 @@ +import gzip import logging +from gettext import gettext as _ +from pathlib import Path from typing import ClassVar from ...core.source_asset import SourceAsset @@ -36,7 +39,7 @@ class SvgImporter(Importer): label = "SVG files" mime_types = ("image/svg+xml",) - extensions = (".svg",) + extensions = (".svg", ".svgz") features: ClassVar[set[ImporterFeature]] = { ImporterFeature.DIRECT_VECTOR, ImporterFeature.BITMAP_TRACING, @@ -44,9 +47,28 @@ class SvgImporter(Importer): ImporterFeature.COLOR_LAYERS, } + def __init__(self, data: bytes, source_file: Path | None = None): + """ + Unpacks gzip-compressed SVG (Inkscape's "Compressed SVG", + .svgz) here, so every phase below and both delegates always + see plain SVG bytes. + """ + super().__init__(data, source_file) + if data[:2] == b"\x1f\x8b": + try: + self.raw_data = gzip.decompress(data) + except (OSError, EOFError): + self.add_error( + _("This SVGZ file's gzip data could not be decompressed.") + ) + self.raw_data = b"" + def scan(self) -> ImportManifest: # Use Vector importer for scanning as it's lightweight/standard - return SvgVectorImporter(self.raw_data, self.source_file).scan() + manifest = SvgVectorImporter(self.raw_data, self.source_file).scan() + manifest.warnings.extend(self._warnings) + manifest.errors.extend(self._errors) + return manifest def get_doc_items( self, vectorization_spec: VectorizationSpec | None = None diff --git a/tests/image/svg/test_svgz.py b/tests/image/svg/test_svgz.py new file mode 100644 index 000000000..2ddcf1ba8 --- /dev/null +++ b/tests/image/svg/test_svgz.py @@ -0,0 +1,65 @@ +import gzip +from pathlib import Path + +from rayforge.image import importer_registry +from rayforge.image.svg.importer import SvgImporter + +SVG_WITH_LAYER = b""" + + + + + +""" + +CORRUPT_GZIP = b"\x1f\x8b" + b"not really gzip" + + +def test_registry_resolves_svgz(): + assert importer_registry.get_by_extension(".svgz") is SvgImporter + + +def test_extensions_include_svgz(): + assert ".svgz" in SvgImporter.extensions + + +def test_scan_svgz_matches_plain_svg(): + plain = SvgImporter(SVG_WITH_LAYER).scan() + packed = SvgImporter(gzip.compress(SVG_WITH_LAYER)).scan() + assert packed.errors == [] + assert packed.natural_size_mm == plain.natural_size_mm + assert [lay.name for lay in packed.layers] == [ + lay.name for lay in plain.layers + ] + + +def test_get_doc_items_from_svgz(): + result = SvgImporter(gzip.compress(SVG_WITH_LAYER)).get_doc_items() + assert result is not None + assert result.errors == [] + assert result.payload is not None + assert len(result.payload.items) > 0 + + +def test_plain_svg_is_passed_through_unchanged(): + importer = SvgImporter(SVG_WITH_LAYER) + assert importer.raw_data == SVG_WITH_LAYER + + +def test_corrupt_gzip_reports_an_error(): + manifest = SvgImporter(CORRUPT_GZIP).scan() + assert any( + "gzip" in e.lower() or "svgz" in e.lower() for e in manifest.errors + ) + + +def test_imports_a_real_inkscape_svgz(tmp_path: Path): + src = Path(__file__).parent / "circle.svg" + gz = tmp_path / "circle.svgz" + gz.write_bytes(gzip.compress(src.read_bytes())) + importer = SvgImporter(gz.read_bytes(), gz) + manifest = importer.scan() + assert manifest.errors == [] + assert manifest.natural_size_mm is not None From 330e5d86d29251830cc84e2ffcc00b2b606ee70a Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Mon, 12 Oct 2026 16:53:44 +0200 Subject: [PATCH 055/104] feat: register SVGZ with the desktop, installers and docs Add image/svg+xml-compressed to the SVG importer, the desktop file and the AppStream metainfo, register .svgz in the Windows installer, and document the format in the import and file-format pages. Refs #546 --- CHANGELOG.md | 3 +++ data/org.rayforge.rayforge.desktop | 2 +- data/org.rayforge.rayforge.metainfo.xml | 1 + rayforge/image/svg/importer.py | 2 +- scripts/win/win_installer.nsi | 10 ++++++++++ tests/image/svg/test_svgz.py | 11 +++++++++++ website/docs/files/formats.md | 7 +++++-- website/docs/files/importing.md | 2 +- 8 files changed, 33 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 618e33fde..f929826e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 the page size (#517) - Import: GIF, TIFF and WebP bitmaps, traced like PNG. Animated GIFs and multi-page TIFFs import their first frame or page (#517) +- Import: compressed SVG (`.svgz`, Inkscape's "Compressed SVG"), with + the same options as plain SVG. The file type is registered with the + desktop, AppStream and the Windows installer (#546) - Engrave: more dithering algorithms for photos. The error-diffusion kernels Atkinson, Stucki, Jarvis-Judice-Ninke, Sierra (3-row, 2-row, Lite) and Burkes, with an optional serpentine scan, plus a diff --git a/data/org.rayforge.rayforge.desktop b/data/org.rayforge.rayforge.desktop index 953a7b201..8617f97aa 100644 --- a/data/org.rayforge.rayforge.desktop +++ b/data/org.rayforge.rayforge.desktop @@ -6,7 +6,7 @@ Name=Rayforge Comment=Laser cutting and engraving Categories=Graphics;2DGraphics;VectorGraphics;Engineering; StartupNotify=true -MimeType=application/x-rayforge-project;application/x-rayforge-sketch;application/x-ruida;image/png;image/bmp;image/jpeg;image/svg+xml;image/vnd.dxf; +MimeType=application/x-rayforge-project;application/x-rayforge-sketch;application/x-ruida;image/png;image/bmp;image/jpeg;image/svg+xml;image/svg+xml-compressed;image/vnd.dxf; Icon=org.rayforge.rayforge Exec=rayforge diff --git a/data/org.rayforge.rayforge.metainfo.xml b/data/org.rayforge.rayforge.metainfo.xml index 072849a28..a95e46c89 100644 --- a/data/org.rayforge.rayforge.metainfo.xml +++ b/data/org.rayforge.rayforge.metainfo.xml @@ -52,6 +52,7 @@ image/bmp image/jpeg image/svg+xml + image/svg+xml-compressed image/vnd.dxf diff --git a/rayforge/image/svg/importer.py b/rayforge/image/svg/importer.py index f1f3966cd..b8c4d2ebd 100644 --- a/rayforge/image/svg/importer.py +++ b/rayforge/image/svg/importer.py @@ -38,7 +38,7 @@ class SvgImporter(Importer): """ label = "SVG files" - mime_types = ("image/svg+xml",) + mime_types = ("image/svg+xml", "image/svg+xml-compressed") extensions = (".svg", ".svgz") features: ClassVar[set[ImporterFeature]] = { ImporterFeature.DIRECT_VECTOR, diff --git a/scripts/win/win_installer.nsi b/scripts/win/win_installer.nsi index f71336c8d..8beea76ba 100644 --- a/scripts/win/win_installer.nsi +++ b/scripts/win/win_installer.nsi @@ -125,6 +125,12 @@ Section "MainSection" SEC01 WriteRegStr HKCR "Rayforge.SvgFile\DefaultIcon" "" "$INSTDIR\${EXECUTABLE_NAME},0" WriteRegStr HKCR "Rayforge.SvgFile\shell\open\command" "" '"$INSTDIR\${EXECUTABLE_NAME}" "%1"' + ; Register .svgz file type + WriteRegStr HKCR ".svgz" "" "Rayforge.SvgzFile" + WriteRegStr HKCR "Rayforge.SvgzFile" "" "Compressed SVG Image" + WriteRegStr HKCR "Rayforge.SvgzFile\DefaultIcon" "" "$INSTDIR\${EXECUTABLE_NAME},0" + WriteRegStr HKCR "Rayforge.SvgzFile\shell\open\command" "" '"$INSTDIR\${EXECUTABLE_NAME}" "%1"' + ; Register .dxf file type WriteRegStr HKCR ".dxf" "" "Rayforge.DxfFile" WriteRegStr HKCR "Rayforge.DxfFile" "" "DXF Drawing" @@ -177,6 +183,10 @@ Section "Uninstall" DeleteRegKey HKCR ".svg" DeleteRegKey HKCR "Rayforge.SvgFile" + ; Unregister .svgz file type + DeleteRegKey HKCR ".svgz" + DeleteRegKey HKCR "Rayforge.SvgzFile" + ; Unregister .dxf file type DeleteRegKey HKCR ".dxf" DeleteRegKey HKCR "Rayforge.DxfFile" diff --git a/tests/image/svg/test_svgz.py b/tests/image/svg/test_svgz.py index 2ddcf1ba8..d96364d79 100644 --- a/tests/image/svg/test_svgz.py +++ b/tests/image/svg/test_svgz.py @@ -21,6 +21,17 @@ def test_registry_resolves_svgz(): assert importer_registry.get_by_extension(".svgz") is SvgImporter +def test_registry_resolves_compressed_svg_mime_type(): + assert ( + importer_registry.get_by_mime_type("image/svg+xml-compressed") + is SvgImporter + ) + + +def test_plain_svg_stays_the_primary_mime_type(): + assert SvgImporter.mime_types[0] == "image/svg+xml" + + def test_extensions_include_svgz(): assert ".svgz" in SvgImporter.extensions diff --git a/website/docs/files/formats.md b/website/docs/files/formats.md index 62d6c2392..276c58abc 100644 --- a/website/docs/files/formats.md +++ b/website/docs/files/formats.md @@ -39,8 +39,11 @@ capabilities, limitations, and recommendations. ### SVG (Scalable Vector Graphics) -**Extension:** `.svg` **MIME Type:** `image/svg+xml` **Import:** Direct vector parsing or bitmap -trace **Export:** Object export (geometry only) +**Extension:** `.svg`, `.svgz` **MIME Type:** `image/svg+xml`, `image/svg+xml-compressed` +**Import:** Direct vector parsing or bitmap trace **Export:** Object export (geometry only) + +Compressed SVG (`.svgz`, Inkscape's "Compressed SVG") is unpacked on import and then behaves +exactly like a plain SVG file. **What is SVG?** diff --git a/website/docs/files/importing.md b/website/docs/files/importing.md index 87c2cff4c..1014272a5 100644 --- a/website/docs/files/importing.md +++ b/website/docs/files/importing.md @@ -15,7 +15,7 @@ import files and optimize them for best results. | Format | Extension | Import Method | Best For | | --------------- | ----------------- | ------------------------------------------- | ------------------------------- | -| **SVG** | `.svg` | Direct vectors or trace | Vector graphics, logos, designs | +| **SVG** | `.svg`, `.svgz` | Direct vectors or trace | Vector graphics, logos, designs | | **DXF** | `.dxf` | Direct vectors | CAD drawings, technical designs | | **PDF** | `.pdf` | Direct vectors or trace | Documents with vector content | | **LightBurn** | `.lbrn`, `.lbrn2` | Direct vectors | LightBurn project files | From a3305aec679064ea7afefc785ae655a962439d67 Mon Sep 17 00:00:00 2001 From: Samuel Abels Date: Mon, 12 Oct 2026 17:16:22 +0200 Subject: [PATCH 056/104] docs: translate the SVGZ import docs into all site languages Update the SVG sections of formats.md and the SVG rows of importing.md in the de, es, fr, hi, pt-BR, uk and zh-CN translations for the new .svgz extension and MIME type, plus the compressed-SVG paragraph. Also rewrap the English paragraph so pixi run site-format stays idempotent. --- website/docs/files/formats.md | 4 ++-- .../current/files/formats.md | 7 +++++-- .../current/files/importing.md | 2 +- .../current/files/formats.md | 8 ++++++-- .../current/files/importing.md | 2 +- .../current/files/formats.md | 8 ++++++-- .../current/files/importing.md | 2 +- .../current/files/formats.md | 7 +++++-- .../current/files/importing.md | 2 +- .../current/files/formats.md | 8 ++++++-- .../current/files/importing.md | 2 +- .../current/files/formats.md | 8 ++++++-- .../current/files/importing.md | 2 +- .../current/files/formats.md | 6 ++++-- .../current/files/importing.md | 2 +- 15 files changed, 47 insertions(+), 23 deletions(-) diff --git a/website/docs/files/formats.md b/website/docs/files/formats.md index 276c58abc..8a467b8dd 100644 --- a/website/docs/files/formats.md +++ b/website/docs/files/formats.md @@ -42,8 +42,8 @@ capabilities, limitations, and recommendations. **Extension:** `.svg`, `.svgz` **MIME Type:** `image/svg+xml`, `image/svg+xml-compressed` **Import:** Direct vector parsing or bitmap trace **Export:** Object export (geometry only) -Compressed SVG (`.svgz`, Inkscape's "Compressed SVG") is unpacked on import and then behaves -exactly like a plain SVG file. +Compressed SVG (`.svgz`, Inkscape's "Compressed SVG") is unpacked on import and then behaves exactly +like a plain SVG file. **What is SVG?** diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/files/formats.md b/website/i18n/de/docusaurus-plugin-content-docs/current/files/formats.md index 3ee90186c..7997115fb 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/files/formats.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/files/formats.md @@ -33,8 +33,11 @@ einschließlich Fähigkeiten, Einschränkungen und Empfehlungen. ### SVG (Scalable Vector Graphics) -**Erweiterung:** `.svg` **MIME-Typ:** `image/svg+xml` **Import:** Direktes Vektor-Parsing oder -Bitmap-Tracing **Export:** Objekt-Export (nur Geometrie) +**Erweiterung:** `.svg`, `.svgz` **MIME-Typ:** `image/svg+xml`, `image/svg+xml-compressed` +**Import:** Direktes Vektor-Parsing oder Bitmap-Tracing **Export:** Objekt-Export (nur Geometrie) + +Komprimiertes SVG (`.svgz`, in Inkscape "Komprimiertes SVG") wird beim Import entpackt und verhält +sich danach genau wie eine normale SVG-Datei. **Was ist SVG?** diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/files/importing.md b/website/i18n/de/docusaurus-plugin-content-docs/current/files/importing.md index b0ca39161..b3417b384 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/files/importing.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/files/importing.md @@ -9,7 +9,7 @@ Rasterformate. Diese Seite erklärt, wie du Dateien importierst und für beste E | Format | Erweiterung | Importmethode | Am besten für | | --------------- | ----------------- | ---------------------------------------------------- | ------------------------------------ | -| **SVG** | `.svg` | Direkte Vektoren oder Tracing | Vektorgrafiken, Logos, Designs | +| **SVG** | `.svg`, `.svgz` | Direkte Vektoren oder Tracing | Vektorgrafiken, Logos, Designs | | **DXF** | `.dxf` | Direkte Vektoren | CAD-Zeichnungen, technische Designs | | **PDF** | `.pdf` | Direkte Vektoren oder Tracing | Dokumente mit Vektorinhalt | | **LightBurn** | `.lbrn`, `.lbrn2` | Direkte Vektoren | LightBurn-Projekte | diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/files/formats.md b/website/i18n/es/docusaurus-plugin-content-docs/current/files/formats.md index 86d83b70f..d16c1b140 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/files/formats.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/files/formats.md @@ -33,8 +33,12 @@ Rayforge, incluyendo capacidades, limitaciones y recomendaciones. ### SVG (Scalable Vector Graphics) -**Extensión:** `.svg` **Tipo MIME:** `image/svg+xml` **Importación:** Análisis vectorial directo o -traza de mapa de bits **Exportación:** Exportar objeto (solo geometría) +**Extensión:** `.svg`, `.svgz` **Tipo MIME:** `image/svg+xml`, `image/svg+xml-compressed` +**Importación:** Análisis vectorial directo o traza de mapa de bits **Exportación:** Exportar objeto +(solo geometría) + +El SVG comprimido (`.svgz`, "SVG comprimido" de Inkscape) se descomprime al importarlo y luego se +comporta exactamente como un archivo SVG normal. **¿Qué es SVG?** diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/files/importing.md b/website/i18n/es/docusaurus-plugin-content-docs/current/files/importing.md index 477280ee0..8c1b249e5 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/files/importing.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/files/importing.md @@ -9,7 +9,7 @@ bits. Esta página explica cómo importar archivos y optimizarlos para obtener l | Formato | Extensión | Método de importación | Mejor para | | --------------- | ----------------- | ------------------------------------------------ | ------------------------------------- | -| **SVG** | `.svg` | Vectores directos o trazo | Gráficos vectoriales, logos, diseños | +| **SVG** | `.svg`, `.svgz` | Vectores directos o trazo | Gráficos vectoriales, logos, diseños | | **DXF** | `.dxf` | Vectores directos | Planos CAD, diseños técnicos | | **PDF** | `.pdf` | Vectores directos o trazo | Documentos con contenido vectorial | | **LightBurn** | `.lbrn`, `.lbrn2` | Vectores directos | Proyectos LightBurn | diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/files/formats.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/files/formats.md index eba5f9e0b..9d7db2fdf 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/files/formats.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/files/formats.md @@ -33,8 +33,12 @@ Rayforge, y compris les capacités, les limitations et les recommandations. ### SVG (Scalable Vector Graphics) -**Extension :** `.svg` **Type MIME :** `image/svg+xml` **Importation :** Analyse vectorielle directe -ou tracé bitmap **Exportation :** Export d'objet (géométrie uniquement) +**Extension :** `.svg`, `.svgz` **Type MIME :** `image/svg+xml`, `image/svg+xml-compressed` +**Importation :** Analyse vectorielle directe ou tracé bitmap **Exportation :** Export d'objet +(géométrie uniquement) + +Le SVG compressé (`.svgz`, "SVG compressé" d'Inkscape) est décompressé lors de l'importation et se +comporte ensuite exactement comme un fichier SVG ordinaire. **Qu'est-ce que le SVG ?** diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/files/importing.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/files/importing.md index ec51e43a7..a53724a67 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/files/importing.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/files/importing.md @@ -10,7 +10,7 @@ meilleurs résultats. | Format | Extension | Méthode d'importation | Idéal pour | | --------------- | ----------------- | --------------------------------------------------------- | -------------------------------------------- | -| **SVG** | `.svg` | Vecteurs directs ou vectorisation | Graphiques vectoriels, logos, dessins | +| **SVG** | `.svg`, `.svgz` | Vecteurs directs ou vectorisation | Graphiques vectoriels, logos, dessins | | **DXF** | `.dxf` | Vecteurs directs | Dessins CAO, plans techniques | | **PDF** | `.pdf` | Vecteurs directs ou vectorisation | Documents avec contenu vectoriel | | **LightBurn** | `.lbrn`, `.lbrn2` | Vecteurs directs | Projets LightBurn | diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/files/formats.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/files/formats.md index 38f2067cd..c7ed9993c 100644 --- a/website/i18n/hi/docusaurus-plugin-content-docs/current/files/formats.md +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/files/formats.md @@ -39,8 +39,11 @@ description: ### SVG (Scalable Vector Graphics) -**एक्सटेंशन:** `.svg` **MIME प्रकार:** `image/svg+xml` **आयात:** सीधा वेक्टर पार्सिंग या बिटमैप -ट्रेस **निर्यात:** ऑब्जेक्ट निर्यात (केवल ज्यामिति) +**एक्सटेंशन:** `.svg`, `.svgz` **MIME प्रकार:** `image/svg+xml`, `image/svg+xml-compressed` +**आयात:** सीधा वेक्टर पार्सिंग या बिटमैप ट्रेस **निर्यात:** ऑब्जेक्ट निर्यात (केवल ज्यामिति) + +संपीडित SVG (`.svgz`, Inkscape का "कंप्रेस्ड SVG") आयात के समय अनपैक कर दिया जाता है और इसके बाद यह +बिल्कुल किसी साधारण SVG फ़ाइल की तरह व्यवहार करता है। **SVG क्या है?** diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/files/importing.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/files/importing.md index e4523201d..b0bb57cc9 100644 --- a/website/i18n/hi/docusaurus-plugin-content-docs/current/files/importing.md +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/files/importing.md @@ -15,7 +15,7 @@ Rayforge विभिन्न फ़ाइल फ़ॉर्मेट आय | फ़ॉर्मेट | एक्सटेंशन | आयात विधि | सर्वोत्तम के लिए | | --------------- | ----------------- | ----------------------------------------- | -------------------------------- | -| **SVG** | `.svg` | सीधे वेक्टर या ट्रेस | वेक्टर ग्राफ़िक्स, लोगो, डिज़ाइन | +| **SVG** | `.svg`, `.svgz` | सीधे वेक्टर या ट्रेस | वेक्टर ग्राफ़िक्स, लोगो, डिज़ाइन | | **DXF** | `.dxf` | सीधे वेक्टर | CAD चित्र, तकनीकी डिज़ाइन | | **PDF** | `.pdf` | सीधे वेक्टर या ट्रेस | वेक्टर सामग्री युक्त दस्तावेज़ | | **LightBurn** | `.lbrn`, `.lbrn2` | सीधे वेक्टर | LightBurn परियोजना फ़ाइलें | diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/files/formats.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/files/formats.md index 5170c1f60..e5e68d56a 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/files/formats.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/files/formats.md @@ -33,8 +33,12 @@ Rayforge, incluindo capacidades, limitações e recomendações. ### SVG (Gráficos Vetoriais Escaláveis) -**Extensão:** `.svg` **Tipo MIME:** `image/svg+xml` **Importar:** Análise vetorial direta ou traçado -de bitmap **Exportar:** Exportação de objeto (apenas geometria) +**Extensão:** `.svg`, `.svgz` **Tipo MIME:** `image/svg+xml`, `image/svg+xml-compressed` +**Importar:** Análise vetorial direta ou traçado de bitmap **Exportar:** Exportação de objeto +(apenas geometria) + +O SVG comprimido (`.svgz`, "SVG comprimido" do Inkscape) é descompactado na importação e depois se +comporta exatamente como um arquivo SVG comum. **O que é SVG?** diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/files/importing.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/files/importing.md index abcf1268e..daf9fb28e 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/files/importing.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/files/importing.md @@ -9,7 +9,7 @@ explica como importar arquivos e otimizá-los para obter os melhores resultados. | Formato | Extensão | Método de importação | Ideal para | | --------------- | ----------------- | --------------------------------------------------- | ---------------------------------------- | -| **SVG** | `.svg` | Vetores diretos ou vetorização | Gráficos vetoriais, logos, designs | +| **SVG** | `.svg`, `.svgz` | Vetores diretos ou vetorização | Gráficos vetoriais, logos, designs | | **DXF** | `.dxf` | Vetores diretos | Desenhos CAD, projetos técnicos | | **PDF** | `.pdf` | Vetores diretos ou vetorização | Documentos com conteúdo vetorial | | **LightBurn** | `.lbrn`, `.lbrn2` | Vetores diretos | Projetos LightBurn | diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/files/formats.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/files/formats.md index c4327cfa8..5d8916dbe 100644 --- a/website/i18n/uk/docusaurus-plugin-content-docs/current/files/formats.md +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/files/formats.md @@ -33,8 +33,12 @@ ### SVG (Scalable Vector Graphics) -**Розширення:** `.svg` **MIME-тип:** `image/svg+xml` **Імпорт:** Прямий аналіз векторів або -трасування растру **Експорт:** Експорт об'єктів (тільки геометрія) +**Розширення:** `.svg`, `.svgz` **MIME-тип:** `image/svg+xml`, `image/svg+xml-compressed` +**Імпорт:** Прямий аналіз векторів або трасування растру **Експорт:** Експорт об'єктів (тільки +геометрія) + +Стиснутий SVG (`.svgz`, "Стиснутий SVG" в Inkscape) розпаковується під час імпорту, після чого +поводиться точно як звичайний SVG-файл. **Що таке SVG?** diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/files/importing.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/files/importing.md index 8e37f0cf9..23ce05f8d 100644 --- a/website/i18n/uk/docusaurus-plugin-content-docs/current/files/importing.md +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/files/importing.md @@ -9,7 +9,7 @@ Rayforge підтримує імпортування різних файлови | Формат | Розширення | Метод імпорту | Найкраще для | | --------------- | ----------------- | --------------------------------------------------- | ----------------------------------- | -| **SVG** | `.svg` | Прямі вектори або трасування | Векторна графіка, логотипи, дизайни | +| **SVG** | `.svg`, `.svgz` | Прямі вектори або трасування | Векторна графіка, логотипи, дизайни | | **DXF** | `.dxf` | Прямі вектори | CAD-креслення, технічні проекти | | **PDF** | `.pdf` | Прямі вектори або трасування | Документи з векторним вмістом | | **LightBurn** | `.lbrn`, `.lbrn2` | Прямі вектори | Проєкти LightBurn | diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/files/formats.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/files/formats.md index c6c1a7d70..1a017eefa 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/files/formats.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/files/formats.md @@ -32,8 +32,10 @@ ### SVG(可缩放矢量图形) -**扩展名:** `.svg` **MIME 类型:** `image/svg+xml` **导入:** 直接矢量解析或位图描摹 **导出:** -对象导出(仅几何图形) +**扩展名:** `.svg`, `.svgz` **MIME 类型:** `image/svg+xml`, `image/svg+xml-compressed` **导入:** +直接矢量解析或位图描摹 **导出:** 对象导出(仅几何图形) + +压缩 SVG(`.svgz`,Inkscape 的"压缩 SVG")会在导入时解压,之后的行为与普通 SVG 文件完全相同。 **什么是 SVG?** diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/files/importing.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/files/importing.md index 36cc57e66..f0099b2b3 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/files/importing.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/files/importing.md @@ -8,7 +8,7 @@ Rayforge 支持导入多种文件格式,包括矢量格式和光栅格式。 | 格式 | 扩展名 | 导入方式 | 适用场景 | | --------------- | ----------------- | ---------------------------------- | ----------------------- | -| **SVG** | `.svg` | 直接矢量或描摹 | 矢量图形、标志、设计 | +| **SVG** | `.svg`, `.svgz` | 直接矢量或描摹 | 矢量图形、标志、设计 | | **DXF** | `.dxf` | 直接矢量 | CAD 绘图、技术设计 | | **PDF** | `.pdf` | 直接矢量或描摹 | 包含矢量内容的文档 | | **LightBurn** | `.lbrn`, `.lbrn2` | 直接矢量 | LightBurn 项目文件 | From 5ef3cc35e03f5d9587bac3f8c70a53143df0ebc9 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Mon, 12 Oct 2026 18:45:14 +0200 Subject: [PATCH 057/104] refactor: add a non-destructive image edit stack to workpieces Workpieces carry an ordered stack of image edits. The ops come from a new image edit registry that addons fill through the register_image_edits hook (plugin API 22); the core only applies them and skips ops that are not registered. get_vips_image() becomes the private _get_vips_image() for the unedited render, and get_processed_image() applies the stack. Every consumer of the workpiece's pixels goes through it, so the canvas, the 3D preview and the pipeline see the same image. Unregistered ops are reported through the existing missing-features dialog, and steps can hand legacy settings over to the stack after a document loads. Refs #515 --- rayforge/core/doc.py | 35 +++++- rayforge/core/hooks.py | 23 +++- rayforge/core/image_edit/__init__.py | 12 ++ rayforge/core/image_edit/base.py | 60 ++++++++++ rayforge/core/image_edit/registry.py | 44 ++++++++ rayforge/core/image_edit/stack.py | 40 +++++++ rayforge/core/registration.py | 8 ++ rayforge/core/step.py | 9 ++ rayforge/core/workpiece.py | 58 +++++++++- rayforge/ui_gtk/mainwindow.py | 6 +- rayforge/ui_gtk/sim3d/scene_presenter.py | 2 +- tests/core/image_edit/test_image_edit.py | 115 +++++++++++++++++++ tests/core/test_doc_image_edits.py | 70 ++++++++++++ tests/core/test_workpiece_image_edits.py | 138 +++++++++++++++++++++++ 14 files changed, 610 insertions(+), 10 deletions(-) create mode 100644 rayforge/core/image_edit/__init__.py create mode 100644 rayforge/core/image_edit/base.py create mode 100644 rayforge/core/image_edit/registry.py create mode 100644 rayforge/core/image_edit/stack.py create mode 100644 tests/core/image_edit/test_image_edit.py create mode 100644 tests/core/test_doc_image_edits.py create mode 100644 tests/core/test_workpiece_image_edits.py diff --git a/rayforge/core/doc.py b/rayforge/core/doc.py index 8a1da3d1d..c6a45d17e 100644 --- a/rayforge/core/doc.py +++ b/rayforge/core/doc.py @@ -1,5 +1,5 @@ import logging -from collections.abc import Iterable +from collections.abc import Iterable, Sequence from gettext import gettext as _ from typing import ( TYPE_CHECKING, @@ -13,6 +13,7 @@ from ..core.undo import HistoryManager from .asset import IAsset, UnknownAsset from .color import COLOR_PALETTE +from .image_edit import ImageEditStep, missing_image_edit_ops from .item import DocItem from .layer import Layer from .source_asset import SourceAsset @@ -145,6 +146,7 @@ def _deserialize_item(item_data: dict) -> DocItem: doc.set_children(children) doc._active_layer_index = data.get("active_layer_index", 0) + doc.adopt_legacy_image_edits() return doc @@ -511,6 +513,28 @@ def get_layer_uid_for_step(self, step_uid: str) -> str | None: return layer.uid return None + @property + def missing_image_edit_ops(self) -> set[str]: + """Image edit op names used by workpieces but not registered.""" + missing: set[str] = set() + for wp in self.all_workpieces: + missing |= missing_image_edit_ops(wp.image_edits) + return missing + + def adopt_legacy_image_edits(self) -> None: + """ + Moves image edits that older files kept as step settings onto + the workpieces of the step's layer. Steps hand them over only + once, so this is safe to call after every load. + """ + for layer in self.layers: + if not layer.workflow: + continue + for step in layer.workflow.steps: + edits = step.pop_legacy_image_edits() + if edits: + _append_image_edits(layer.all_workpieces, edits) + @property def missing_step_types(self) -> set[str]: """Step type names referenced but not registered in step_registry.""" @@ -524,3 +548,12 @@ def missing_step_types(self) -> set[str]: if step_registry.get(name) is None: missing.add(step.original_step_type or step.typelabel) return missing + + +def _append_image_edits( + workpieces: Iterable[WorkPiece], edits: Sequence[ImageEditStep] +) -> None: + for wp in workpieces: + new = [e for e in edits if e not in wp.image_edits] + if new: + wp.image_edits = (*wp.image_edits, *new) diff --git a/rayforge/core/hooks.py b/rayforge/core/hooks.py index 16abbab0f..c82dc9dad 100644 --- a/rayforge/core/hooks.py +++ b/rayforge/core/hooks.py @@ -4,13 +4,23 @@ hookimpl = pluggy.HookimplMarker("rayforge") MINIMUM_API_VERSION = 16 -PLUGIN_API_VERSION = 21 +PLUGIN_API_VERSION = 22 """ API Changelog ============= +Version 22 +---------- +Added the ``register_image_edits`` hook and the global +``image_edit_registry`` (``rayforge.core.image_edit``). Addons register +``ImageEditOp`` subclasses there; a workpiece applies its +``image_edits`` stack in ``WorkPiece.get_processed_image()``. +``WorkPiece.get_vips_image()`` is now the private +``_get_vips_image()`` and returns the unedited render; callers that +want what the user sees use ``get_processed_image()``. + Version 21 ---------- Renamed ``WorkOriginElement.set_coordinate_space(space)`` to @@ -233,6 +243,17 @@ def register_steps(self, step_registry): step_registry: The global StepRegistry instance. """ + @hookspec + def register_image_edits(self, image_edit_registry): + """ + Called to allow addons to register image editing operations. + + .. versionadded:: 22 + + Args: + image_edit_registry: The global ImageEditRegistry instance. + """ + @hookspec def register_transformers(self, transformer_registry): """ diff --git a/rayforge/core/image_edit/__init__.py b/rayforge/core/image_edit/__init__.py new file mode 100644 index 000000000..0bb45dc80 --- /dev/null +++ b/rayforge/core/image_edit/__init__.py @@ -0,0 +1,12 @@ +from .base import ImageEditOp, ImageEditStep +from .registry import ImageEditRegistry, image_edit_registry +from .stack import apply_image_edits, missing_image_edit_ops + +__all__ = [ + "ImageEditOp", + "ImageEditRegistry", + "ImageEditStep", + "apply_image_edits", + "image_edit_registry", + "missing_image_edit_ops", +] diff --git a/rayforge/core/image_edit/base.py b/rayforge/core/image_edit/base.py new file mode 100644 index 000000000..00bf9d4fe --- /dev/null +++ b/rayforge/core/image_edit/base.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import Mapping +from dataclasses import dataclass, field +from typing import Any, ClassVar + +import pyvips + +_KNOWN_KEYS = frozenset({"op", "params", "enabled"}) + + +@dataclass(frozen=True) +class ImageEditStep: + """ + One non-destructive image editing operation in a workpiece's edit + stack. ``op`` is the registry name of an ImageEditOp; ``params`` + must be JSON-serializable. Keys this version does not know are kept + in ``extra`` and written back, so a newer file survives a round trip. + """ + + op: str + params: Mapping[str, Any] = field(default_factory=dict) + enabled: bool = True + extra: Mapping[str, Any] = field(default_factory=dict, compare=False) + + def to_dict(self) -> dict[str, Any]: + return { + **self.extra, + "op": self.op, + "params": dict(self.params), + "enabled": self.enabled, + } + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> ImageEditStep: + return cls( + op=data["op"], + params=dict(data.get("params", {})), + enabled=data.get("enabled", True), + extra={k: v for k, v in data.items() if k not in _KNOWN_KEYS}, + ) + + +class ImageEditOp(ABC): + """ + An image editing operation. Subclasses are registered with the + ImageEditRegistry under ``NAME``, which is stored in documents and + must never change. + """ + + NAME: ClassVar[str] + LABEL: ClassVar[str] + + @classmethod + @abstractmethod + def apply( + cls, image: pyvips.Image, params: Mapping[str, Any] + ) -> pyvips.Image: + """Returns the edited image; ``image`` is left untouched.""" diff --git a/rayforge/core/image_edit/registry.py b/rayforge/core/image_edit/registry.py new file mode 100644 index 000000000..f2e044ad2 --- /dev/null +++ b/rayforge/core/image_edit/registry.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from .base import ImageEditOp + + +class ImageEditRegistry: + """Registry for image editing operations, keyed by their NAME.""" + + def __init__(self): + self._ops: dict[str, type[ImageEditOp]] = {} + self._addon_items: dict[str, set[str]] = {} + + def register( + self, op_class: type[ImageEditOp], addon_name: str | None = None + ) -> None: + name = op_class.NAME + self._ops[name] = op_class + if addon_name: + self._addon_items.setdefault(addon_name, set()).add(name) + + def unregister(self, name: str) -> bool: + if name not in self._ops: + return False + del self._ops[name] + for items in self._addon_items.values(): + items.discard(name) + return True + + def unregister_all_from_addon(self, addon_name: str) -> int: + items = self._addon_items.pop(addon_name, set()) + count = 0 + for name in items: + if self._ops.pop(name, None) is not None: + count += 1 + return count + + def get(self, name: str) -> type[ImageEditOp] | None: + return self._ops.get(name) + + def all_ops(self) -> dict[str, type[ImageEditOp]]: + return self._ops.copy() + + +image_edit_registry = ImageEditRegistry() diff --git a/rayforge/core/image_edit/stack.py b/rayforge/core/image_edit/stack.py new file mode 100644 index 000000000..de5db8b9e --- /dev/null +++ b/rayforge/core/image_edit/stack.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import logging +from collections.abc import Iterable + +import pyvips + +from .base import ImageEditStep +from .registry import ImageEditRegistry, image_edit_registry + +logger = logging.getLogger(__name__) + + +def apply_image_edits( + image: pyvips.Image, + edits: Iterable[ImageEditStep], + registry: ImageEditRegistry = image_edit_registry, +) -> pyvips.Image: + """ + Applies the enabled edits in order. An op that is not registered + (its addon is missing or disabled) is skipped, so the document stays + usable and keeps the edit for when the op is available again. + """ + for edit in edits: + if not edit.enabled: + continue + op_class = registry.get(edit.op) + if op_class is None: + logger.debug(f"Skipping unregistered image edit '{edit.op}'") + continue + image = op_class.apply(image, edit.params) + return image + + +def missing_image_edit_ops( + edits: Iterable[ImageEditStep], + registry: ImageEditRegistry = image_edit_registry, +) -> set[str]: + """Names of the ops in ``edits`` that are not registered.""" + return {edit.op for edit in edits if registry.get(edit.op) is None} diff --git a/rayforge/core/registration.py b/rayforge/core/registration.py index 3197df3d8..460238614 100644 --- a/rayforge/core/registration.py +++ b/rayforge/core/registration.py @@ -81,6 +81,14 @@ class RegistryEntry: worker_ok=False, needs_window=False, ), + RegistryEntry( + "register_image_edits", + "image_edit_registry", + "rayforge.core.image_edit", + "image_edit_registry", + worker_ok=True, + needs_window=False, + ), RegistryEntry( "register_importers", "importer_registry", diff --git a/rayforge/core/step.py b/rayforge/core/step.py index d75e5d07c..ab5075bd1 100644 --- a/rayforge/core/step.py +++ b/rayforge/core/step.py @@ -24,6 +24,7 @@ from ..pipeline.transformer.registry import transformer_registry from ..shared.units.formatter import format_value from .capability import MachineCapability +from .image_edit import ImageEditStep from .item import DocItem from .step_registry import step_registry from .varset import SpeedVar, VarSet @@ -547,6 +548,14 @@ def apply_import_settings(self, settings: dict[str, Any]) -> None: if cut_speed is not None: self.set_cut_speed(cut_speed) + def pop_legacy_image_edits(self) -> list[ImageEditStep]: + """ + Image edits that an older file stored as a setting of this step. + They are handed over once, after the document is loaded, to the + workpieces of the step's layer; later calls return nothing. + """ + return [] + def to_dict(self) -> dict: """Serializes the step and its configuration to a dictionary.""" step_type = ( diff --git a/rayforge/core/workpiece.py b/rayforge/core/workpiece.py index 8f25caff2..e91e162fc 100644 --- a/rayforge/core/workpiece.py +++ b/rayforge/core/workpiece.py @@ -3,7 +3,7 @@ import logging import math import warnings -from collections.abc import Generator +from collections.abc import Generator, Iterable from copy import deepcopy from dataclasses import asdict from pathlib import Path @@ -28,6 +28,7 @@ from ..context import get_context from .asset_registry import asset_type_registry from .geometry_provider import IGeometryProvider +from .image_edit import ImageEditStep, apply_image_edits from .item import DocItem from .source_asset_segment import SourceAssetSegment from .tab import Tab @@ -89,6 +90,11 @@ def __init__( # The cache for rendered vips images. Key is (width, height). self._render_cache: dict[tuple[int, int], pyvips.Image] = {} + # The non-destructive image edit stack and the cache of rendered + # images with that stack applied. Key is (width, height). + self._image_edits: tuple[ImageEditStep, ...] = () + self._processed_cache: dict[tuple[int, int], pyvips.Image] = {} + # Transient attributes for deserialized instances in subprocesses self._data: bytes | None = None self._original_data: bytes | None = None @@ -219,6 +225,20 @@ def source_segment(self, new_segment: SourceAssetSegment | None): # for triggering the pipeline to re-process the geometry. self.updated.send(self) + @property + def image_edits(self) -> tuple[ImageEditStep, ...]: + """The image edits applied, in order, on top of the render.""" + return self._image_edits + + @image_edits.setter + def image_edits(self, edits: Iterable[ImageEditStep]): + new_edits = tuple(edits) + if new_edits == self._image_edits: + return + self._image_edits = new_edits + self._processed_cache.clear() + self.updated.send(self) + @property def has_edited_boundaries(self) -> bool: """ @@ -251,6 +271,7 @@ def clear_render_cache(self): f"WP {self.uid[:8]}: Clearing all caches (render and boundaries)." ) self._render_cache.clear() + self._processed_cache.clear() self._boundaries_cache = None self._fills_cache = None @@ -583,6 +604,8 @@ def in_world(self) -> WorkPiece: world_wp.source_asset_uid = self.source_asset_uid world_wp._resolved_text_cache = dict(self._resolved_text_cache) + world_wp._image_edits = self._image_edits + # Ensure any edited boundaries are carried over. if self._edited_boundaries is not None: world_wp._edited_boundaries = self._edited_boundaries.copy() @@ -812,9 +835,27 @@ def _process_rendered_image_from_spec( return processed_image - def get_vips_image(self, width: int, height: int) -> pyvips.Image | None: + def get_processed_image( + self, width: int, height: int + ) -> pyvips.Image | None: + """ + The image as the user sees it: the render with the image edit + stack applied. Everything that consumes the workpiece's pixels + (canvas, pipeline, previews) goes through here. + """ + image = self._get_vips_image(width, height) + if image is None or not self._image_edits: + return image + key = (width, height) + processed = self._processed_cache.get(key) + if processed is None: + processed = apply_image_edits(image, self._image_edits) + self._processed_cache[key] = processed + return processed + + def _get_vips_image(self, width: int, height: int) -> pyvips.Image | None: """ - The central hub for rendering a vips image for this workpiece. + Renders the unedited vips image for this workpiece. Orchestrates data retrieval, rendering, cropping, and masking. """ key = (width, height) @@ -895,6 +936,7 @@ def to_dict(self) -> dict[str, Any]: if self._edited_boundaries is not None else None ), + "image_edits": [edit.to_dict() for edit in self._image_edits], "geometry_provider_uid": self.geometry_provider_uid, "geometry_provider_params": self._geometry_provider_params, "source_asset_uid": self.source_asset_uid, @@ -939,6 +981,7 @@ def from_dict(cls, data: dict[str, Any]) -> WorkPiece: "tabs_enabled", "source_segment", "edited_boundaries", + "image_edits", "geometry_provider_uid", "geometry_provider_params", "source_asset_uid", @@ -979,6 +1022,9 @@ def from_dict(cls, data: dict[str, Any]) -> WorkPiece: wp._edited_boundaries = Geometry.from_dict( data["edited_boundaries"] ) + wp._image_edits = tuple( + ImageEditStep.from_dict(d) for d in data.get("image_edits", []) + ) wp.geometry_provider_uid = data.get( "geometry_provider_uid" @@ -1085,7 +1131,7 @@ def render_to_pixels( from ..image import util # This now uses the central hub for rendering. - final_image = self.get_vips_image(width, height) + final_image = self.get_processed_image(width, height) if not final_image: return None normalized_image = util.normalize_to_rgba(final_image) @@ -1176,7 +1222,9 @@ def render_chunk( "or max_memory_size must be provided." ) - vips_image = self.get_vips_image(round(width_px), round(height_px)) + vips_image = self.get_processed_image( + round(width_px), round(height_px) + ) if not vips_image or not isinstance(vips_image, pyvips.Image): logger.warning("Failed to load image for chunking.") return diff --git a/rayforge/ui_gtk/mainwindow.py b/rayforge/ui_gtk/mainwindow.py index 44c5aed66..cf7bbcd33 100644 --- a/rayforge/ui_gtk/mainwindow.py +++ b/rayforge/ui_gtk/mainwindow.py @@ -1625,8 +1625,10 @@ def _on_document_changed(self, sender): GLib.idle_add(self._check_changed_sources) - # Check for missing step types and show dialog if needed - missing_types = new_doc.missing_step_types + # Check for missing step types and image edits and show a dialog + missing_types = ( + new_doc.missing_step_types | new_doc.missing_image_edit_ops + ) if missing_types: dialog = MissingFeaturesDialog(self, missing_types) dialog.present() diff --git a/rayforge/ui_gtk/sim3d/scene_presenter.py b/rayforge/ui_gtk/sim3d/scene_presenter.py index 3b95b9c31..f699262e0 100644 --- a/rayforge/ui_gtk/sim3d/scene_presenter.py +++ b/rayforge/ui_gtk/sim3d/scene_presenter.py @@ -79,7 +79,7 @@ def _workpiece_image_pixels(wp: WorkPiece) -> np.ndarray | None: width_px = max(1, round(width_px * scale)) height_px = max(1, round(height_px * scale)) try: - vips_image = wp.get_vips_image(width_px, height_px) + vips_image = wp.get_processed_image(width_px, height_px) if vips_image is None: return None from ...image.util.vips import normalize_to_rgba diff --git a/tests/core/image_edit/test_image_edit.py b/tests/core/image_edit/test_image_edit.py new file mode 100644 index 000000000..e758abb61 --- /dev/null +++ b/tests/core/image_edit/test_image_edit.py @@ -0,0 +1,115 @@ +from collections.abc import Mapping +from typing import Any + +import pytest +import pyvips + +from rayforge.core.image_edit import ( + ImageEditOp, + ImageEditRegistry, + ImageEditStep, + apply_image_edits, + missing_image_edit_ops, +) + + +class _AddOp(ImageEditOp): + NAME = "test_add" + LABEL = "Add" + + @classmethod + def apply( + cls, image: pyvips.Image, params: Mapping[str, Any] + ) -> pyvips.Image: + return (image + params.get("amount", 1)).cast("uchar") + + +def _gray(value: int) -> pyvips.Image: + return (pyvips.Image.black(2, 2) + value).cast("uchar") + + +@pytest.fixture +def registry() -> ImageEditRegistry: + reg = ImageEditRegistry() + reg.register(_AddOp, addon_name="test_addon") + return reg + + +class TestImageEditStep: + def test_round_trip(self): + step = ImageEditStep("test_add", {"amount": 3}, enabled=False) + assert ImageEditStep.from_dict(step.to_dict()) == step + + def test_defaults(self): + step = ImageEditStep.from_dict({"op": "test_add"}) + assert step.params == {} + assert step.enabled + + def test_unknown_keys_survive_a_round_trip(self): + data = {"op": "future", "params": {"x": 1}, "version": 2} + assert ImageEditStep.from_dict(data).to_dict() == { + "op": "future", + "params": {"x": 1}, + "enabled": True, + "version": 2, + } + + def test_extra_keys_do_not_affect_equality(self): + a = ImageEditStep.from_dict({"op": "test_add", "note": "a"}) + assert a == ImageEditStep("test_add") + + +class TestImageEditRegistry: + def test_get(self, registry): + assert registry.get("test_add") is _AddOp + assert registry.get("nope") is None + + def test_all_ops(self, registry): + assert registry.all_ops() == {"test_add": _AddOp} + + def test_unregister_all_from_addon(self, registry): + assert registry.unregister_all_from_addon("test_addon") == 1 + assert registry.get("test_add") is None + assert registry.unregister_all_from_addon("test_addon") == 0 + + def test_unregister(self, registry): + assert registry.unregister("test_add") + assert not registry.unregister("test_add") + assert registry.unregister_all_from_addon("test_addon") == 0 + + +class TestApplyImageEdits: + def test_applies_ops_in_order(self, registry): + edits = [ + ImageEditStep("test_add", {"amount": 2}), + ImageEditStep("test_add", {"amount": 5}), + ] + result = apply_image_edits(_gray(10), edits, registry) + assert result.avg() == 17 + + def test_skips_disabled_steps(self, registry): + edits = [ImageEditStep("test_add", {"amount": 2}, enabled=False)] + assert apply_image_edits(_gray(10), edits, registry).avg() == 10 + + def test_skips_unregistered_ops(self, registry): + edits = [ + ImageEditStep("future_op"), + ImageEditStep("test_add", {"amount": 1}), + ] + assert apply_image_edits(_gray(10), edits, registry).avg() == 11 + + def test_no_edits_returns_the_same_image(self, registry): + image = _gray(10) + assert apply_image_edits(image, [], registry) is image + + +def test_missing_image_edit_ops(registry): + edits = [ + ImageEditStep("test_add"), + ImageEditStep("future_op"), + ImageEditStep("other_op", enabled=False), + ] + assert missing_image_edit_ops(edits, registry) == { + "future_op", + "other_op", + } diff --git a/tests/core/test_doc_image_edits.py b/tests/core/test_doc_image_edits.py new file mode 100644 index 000000000..ba6483acb --- /dev/null +++ b/tests/core/test_doc_image_edits.py @@ -0,0 +1,70 @@ +from rayforge.core.doc import Doc +from rayforge.core.image_edit import ImageEditStep +from rayforge.core.layer import Layer +from rayforge.core.step import Step +from rayforge.core.workpiece import WorkPiece + +INVERT = ImageEditStep("test_invert") + + +class _LegacyStep(Step): + def __init__(self, edits): + super().__init__(typelabel="Legacy") + self._legacy = list(edits) + + def pop_legacy_image_edits(self): + edits, self._legacy = self._legacy, [] + return edits + + +def _doc_with(step: Step, *workpieces: WorkPiece) -> Doc: + doc = Doc() + for wp in workpieces: + doc.active_layer.add_child(wp) + doc.active_layer.workflow.add_step(step) + return doc + + +def test_base_step_has_no_legacy_image_edits(): + assert Step(typelabel="Plain").pop_legacy_image_edits() == [] + + +def test_legacy_edits_move_to_the_layers_workpieces(): + a, b = WorkPiece("a"), WorkPiece("b") + doc = _doc_with(_LegacyStep([INVERT]), a, b) + doc.adopt_legacy_image_edits() + assert a.image_edits == (INVERT,) + assert b.image_edits == (INVERT,) + + +def test_other_layers_are_untouched(): + inside, outside = WorkPiece("in"), WorkPiece("out") + doc = _doc_with(_LegacyStep([INVERT]), inside) + other = Layer("Other") + doc.add_layer(other) + other.add_child(outside) + doc.adopt_legacy_image_edits() + assert outside.image_edits == () + + +def test_adoption_is_idempotent(): + wp = WorkPiece("a") + doc = _doc_with(_LegacyStep([INVERT]), wp) + doc.adopt_legacy_image_edits() + doc.adopt_legacy_image_edits() + assert wp.image_edits == (INVERT,) + + +def test_an_edit_already_present_is_not_duplicated(): + wp = WorkPiece("a") + wp.image_edits = [INVERT] + doc = _doc_with(_LegacyStep([INVERT]), wp) + doc.adopt_legacy_image_edits() + assert wp.image_edits == (INVERT,) + + +def test_missing_image_edit_ops(): + wp = WorkPiece("a") + wp.image_edits = [ImageEditStep("not_installed")] + doc = _doc_with(Step(typelabel="Plain"), wp) + assert doc.missing_image_edit_ops == {"not_installed"} diff --git a/tests/core/test_workpiece_image_edits.py b/tests/core/test_workpiece_image_edits.py new file mode 100644 index 000000000..58aeaa472 --- /dev/null +++ b/tests/core/test_workpiece_image_edits.py @@ -0,0 +1,138 @@ +from collections.abc import Mapping +from pathlib import Path +from typing import Any, cast + +import pytest +import pyvips + +from rayforge.core.doc import Doc +from rayforge.core.image_edit import ( + ImageEditOp, + ImageEditStep, + image_edit_registry, +) +from rayforge.core.vectorization_spec import LayerImportMode, PassthroughSpec +from rayforge.core.workpiece import WorkPiece +from rayforge.image import import_file + +SVG = b""" + +""" + + +class _BlankOp(ImageEditOp): + NAME = "test_blank" + LABEL = "Blank" + calls = 0 + + @classmethod + def apply( + cls, image: pyvips.Image, params: Mapping[str, Any] + ) -> pyvips.Image: + cls.calls += 1 + return (image * 0 + 255).cast("uchar") + + +@pytest.fixture +def blank_op(): + image_edit_registry.register(_BlankOp, addon_name="test_wp_edits") + _BlankOp.calls = 0 + yield _BlankOp + image_edit_registry.unregister_all_from_addon("test_wp_edits") + + +@pytest.fixture +def workpiece(tmp_path: Path) -> WorkPiece: + path = tmp_path / "square.svg" + path.write_bytes(SVG) + payload = import_file( + path, + vectorization_spec=PassthroughSpec( + layer_import_mode=LayerImportMode.FLATTEN + ), + ) + assert payload is not None + wp = cast(WorkPiece, payload.items[0]) + doc = Doc() + doc.add_asset(payload.source) + doc.active_layer.add_child(wp) + return wp + + +def _mean_rgb(image: pyvips.Image) -> float: + return image.extract_band(0, n=3).avg() + + +def test_new_workpiece_has_no_edits(workpiece): + assert workpiece.image_edits == () + + +def test_processed_image_without_edits_is_the_base_render(workpiece): + base = workpiece._get_vips_image(20, 20) + assert workpiece.get_processed_image(20, 20) is base + + +def test_processed_image_applies_the_stack(workpiece, blank_op): + base_mean = _mean_rgb(workpiece._get_vips_image(20, 20)) + workpiece.image_edits = [ImageEditStep(blank_op.NAME)] + assert _mean_rgb(workpiece.get_processed_image(20, 20)) == 255 + assert _mean_rgb(workpiece._get_vips_image(20, 20)) == base_mean + + +def test_render_to_pixels_uses_the_processed_image(workpiece, blank_op): + workpiece.image_edits = [ImageEditStep(blank_op.NAME)] + surface = workpiece.render_to_pixels(20, 20) + assert surface is not None + assert set(bytes(surface.get_data())) == {255} + + +def test_processed_image_is_cached(workpiece, blank_op): + workpiece.image_edits = [ImageEditStep(blank_op.NAME)] + first = workpiece.get_processed_image(20, 20) + assert workpiece.get_processed_image(20, 20) is first + assert blank_op.calls == 1 + + +def test_setting_edits_invalidates_and_notifies(workpiece, blank_op): + workpiece.get_processed_image(20, 20) + seen = [] + workpiece.updated.connect(seen.append, weak=False) + workpiece.image_edits = [ImageEditStep(blank_op.NAME)] + assert seen == [workpiece] + assert _mean_rgb(workpiece.get_processed_image(20, 20)) == 255 + + +def test_setting_equal_edits_does_not_notify(workpiece, blank_op): + workpiece.image_edits = [ImageEditStep(blank_op.NAME)] + seen = [] + workpiece.updated.connect(seen.append, weak=False) + workpiece.image_edits = (ImageEditStep(blank_op.NAME),) + assert seen == [] + + +def test_unregistered_op_is_skipped(workpiece): + workpiece.image_edits = [ImageEditStep("not_installed")] + base = workpiece._get_vips_image(20, 20) + assert _mean_rgb(workpiece.get_processed_image(20, 20)) == _mean_rgb(base) + + +def test_edits_survive_serialization(workpiece): + edits = ( + ImageEditStep("not_installed", {"x": 1}), + ImageEditStep("test_blank", enabled=False), + ) + workpiece.image_edits = edits + restored = WorkPiece.from_dict(workpiece.to_dict()) + assert restored.image_edits == edits + + +def test_old_documents_load_without_edits(workpiece): + data = workpiece.to_dict() + data.pop("image_edits", None) + assert WorkPiece.from_dict(data).image_edits == () + + +def test_in_world_copies_the_edits(workpiece): + workpiece.image_edits = [ImageEditStep("test_blank")] + assert workpiece.in_world().image_edits == workpiece.image_edits From 9dab837397188909aabfb409b11520804246d90d Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Mon, 12 Oct 2026 18:45:14 +0200 Subject: [PATCH 058/104] feat: add the Image Editing addon with Invert Image The addon registers an Invert edit that inverts the colour and keeps alpha, and a Tools > Invert Image action that toggles it on the selected workpieces as one undo step. Refs #515 --- .../image_editing/__init__.py | 1 + .../image_editing/commands.py | 44 ++++++++++++++ .../image_editing/frontend.py | 59 +++++++++++++++++++ .../image_editing/ops.py | 29 +++++++++ .../image_editing/worker.py | 19 ++++++ .../rayforge-addon.yaml | 12 ++++ .../tests/conftest.py | 18 ++++++ .../tests/test_commands.py | 51 ++++++++++++++++ .../tests/test_ops.py | 33 +++++++++++ .../tests/test_raster_equivalence.py | 52 ++++++++++++++++ website/docs/addons/image-editing.md | 37 ++++++++++++ website/sidebars.js | 1 + 12 files changed, 356 insertions(+) create mode 100644 rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/__init__.py create mode 100644 rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/commands.py create mode 100644 rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/frontend.py create mode 100644 rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/ops.py create mode 100644 rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/worker.py create mode 100644 rayforge/builtin_addons/rayforge-addon-image-editing/rayforge-addon.yaml create mode 100644 rayforge/builtin_addons/rayforge-addon-image-editing/tests/conftest.py create mode 100644 rayforge/builtin_addons/rayforge-addon-image-editing/tests/test_commands.py create mode 100644 rayforge/builtin_addons/rayforge-addon-image-editing/tests/test_ops.py create mode 100644 rayforge/builtin_addons/rayforge-addon-image-editing/tests/test_raster_equivalence.py create mode 100644 website/docs/addons/image-editing.md diff --git a/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/__init__.py b/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/__init__.py new file mode 100644 index 000000000..4d02767d3 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/__init__.py @@ -0,0 +1 @@ +"""Non-destructive image editing tools for workpieces.""" diff --git a/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/commands.py b/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/commands.py new file mode 100644 index 000000000..cc44fdc7c --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/commands.py @@ -0,0 +1,44 @@ +from collections.abc import Sequence + +from rayforge.core.image_edit import ImageEditStep +from rayforge.core.undo import ChangePropertyCommand, HistoryManager +from rayforge.core.workpiece import WorkPiece + + +def _toggled( + edits: tuple[ImageEditStep, ...], edit: ImageEditStep, add: bool +) -> tuple[ImageEditStep, ...]: + if add: + return edits if edit in edits else (*edits, edit) + return tuple(e for e in edits if e != edit) + + +def toggle_image_edit( + history: HistoryManager, + workpieces: Sequence[WorkPiece], + edit: ImageEditStep, + name: str, +) -> bool: + """ + Removes ``edit`` when every workpiece already has it and appends it + to the others otherwise, as one undo step. Returns True when the + edit was added. + """ + if not workpieces: + return False + add = any(edit not in wp.image_edits for wp in workpieces) + with history.transaction(name) as t: + for wp in workpieces: + new_edits = _toggled(wp.image_edits, edit, add) + if new_edits == wp.image_edits: + continue + t.execute( + ChangePropertyCommand( + target=wp, + property_name="image_edits", + new_value=new_edits, + old_value=wp.image_edits, + name=name, + ) + ) + return add diff --git a/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/frontend.py b/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/frontend.py new file mode 100644 index 000000000..89894f0ca --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/frontend.py @@ -0,0 +1,59 @@ +""" +Frontend entry point for the image_editing addon. + +Adds the image editing tools to the Tools menu. They act on the selected +workpieces and are undoable. +""" + +from gettext import gettext as _ + +from gi.repository import Gio + +from rayforge.core.hooks import hookimpl +from rayforge.ui_gtk.action_registry import MenuPlacement, action_registry +from rayforge.ui_gtk.actions import action_extension_registry + +from .commands import toggle_image_edit +from .ops import INVERT + +ADDON_NAME = "image_editing" +INVERT_ACTION = "invert-image" + + +def _register_actions(action_manager): + action = Gio.SimpleAction.new(INVERT_ACTION, None) + + def on_activate(action, param): + window = action_manager.win + toggle_image_edit( + window.doc_editor.history_manager, + window.surface.get_selected_workpieces(), + INVERT, + _("Invert Image"), + ) + + action.connect("activate", on_activate) + action_manager.win.add_action(action) + action_manager.actions[INVERT_ACTION] = action + action_registry.register( + action_name=INVERT_ACTION, + action=action, + addon_name=ADDON_NAME, + label=_("Invert Image"), + menu=MenuPlacement(menu_id="tools", priority=60), + ) + + +def _update_action_states(action_manager): + action = action_manager.actions.get(INVERT_ACTION) + if action: + selected = action_manager.win.surface.get_selected_workpieces() + action.set_enabled(bool(selected)) + + +@hookimpl +def register_actions(action_registry): + action_extension_registry.register_setup(_register_actions, ADDON_NAME) + action_extension_registry.register_state_update( + _update_action_states, ADDON_NAME + ) diff --git a/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/ops.py b/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/ops.py new file mode 100644 index 000000000..92dbaaace --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/ops.py @@ -0,0 +1,29 @@ +from collections.abc import Mapping +from gettext import gettext as _ +from typing import Any + +import pyvips + +from rayforge.core.image_edit import ImageEditOp, ImageEditStep + + +class InvertOp(ImageEditOp): + """Inverts the colour of the image and leaves its alpha alone.""" + + NAME = "invert" + LABEL = _("Invert") + + @classmethod + def apply( + cls, image: pyvips.Image, params: Mapping[str, Any] + ) -> pyvips.Image: + if not image.hasalpha(): + return image.invert() + color = image.extract_band(0, n=image.bands - 1) + alpha = image.extract_band(image.bands - 1) + return color.invert().bandjoin(alpha) + + +INVERT = ImageEditStep(InvertOp.NAME) + +ALL_OPS = (InvertOp,) diff --git a/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/worker.py b/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/worker.py new file mode 100644 index 000000000..19d360f7b --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/worker.py @@ -0,0 +1,19 @@ +""" +Backend entry point for the image_editing addon. + +Registers the image edit ops, which the pipeline needs in every process +that renders workpieces. +""" + +from rayforge.core.hooks import hookimpl + +from .ops import ALL_OPS + +ADDON_NAME = "image_editing" + + +@hookimpl +def register_image_edits(image_edit_registry): + """Register the image edit ops with the image edit registry.""" + for op_class in ALL_OPS: + image_edit_registry.register(op_class, addon_name=ADDON_NAME) diff --git a/rayforge/builtin_addons/rayforge-addon-image-editing/rayforge-addon.yaml b/rayforge/builtin_addons/rayforge-addon-image-editing/rayforge-addon.yaml new file mode 100644 index 000000000..3b44d9ea6 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-image-editing/rayforge-addon.yaml @@ -0,0 +1,12 @@ +name: image_editing +display_name: "Image Editing" +description: "Non-destructive image editing tools for workpieces" +api_version: 22 +author: + name: "Rayforge Team" + email: "noreply@rayforge.org" +provides: + worker: "image_editing.worker" + frontend: "image_editing.frontend" +license: + name: "MIT" diff --git a/rayforge/builtin_addons/rayforge-addon-image-editing/tests/conftest.py b/rayforge/builtin_addons/rayforge-addon-image-editing/tests/conftest.py new file mode 100644 index 000000000..610cc9fd8 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-image-editing/tests/conftest.py @@ -0,0 +1,18 @@ +""" +Pytest configuration for image_editing builtin addon tests. +""" + +import pytest +from image_editing.ops import InvertOp + +from rayforge.core.image_edit import image_edit_registry + +ADDON_NAME = "image_editing" + + +@pytest.fixture(scope="session", autouse=True) +def register_image_edits(): + """Registers the addon's image edit ops for all tests.""" + image_edit_registry.register(InvertOp, addon_name=ADDON_NAME) + yield + image_edit_registry.unregister_all_from_addon(ADDON_NAME) diff --git a/rayforge/builtin_addons/rayforge-addon-image-editing/tests/test_commands.py b/rayforge/builtin_addons/rayforge-addon-image-editing/tests/test_commands.py new file mode 100644 index 000000000..2a975e189 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-image-editing/tests/test_commands.py @@ -0,0 +1,51 @@ +from image_editing.commands import toggle_image_edit +from image_editing.ops import INVERT + +from rayforge.core.image_edit import ImageEditStep +from rayforge.core.undo import HistoryManager +from rayforge.core.workpiece import WorkPiece + +OTHER = ImageEditStep("other") + + +def test_adds_the_edit_to_every_workpiece(): + a, b = WorkPiece("a"), WorkPiece("b") + b.image_edits = [OTHER] + assert toggle_image_edit(HistoryManager(), [a, b], INVERT, "Invert") + assert a.image_edits == (INVERT,) + assert b.image_edits == (OTHER, INVERT) + + +def test_removes_the_edit_when_every_workpiece_has_it(): + a, b = WorkPiece("a"), WorkPiece("b") + a.image_edits = [INVERT] + b.image_edits = [OTHER, INVERT] + assert not toggle_image_edit(HistoryManager(), [a, b], INVERT, "Invert") + assert a.image_edits == () + assert b.image_edits == (OTHER,) + + +def test_mixed_selection_adds_where_missing(): + a, b = WorkPiece("a"), WorkPiece("b") + a.image_edits = [INVERT] + assert toggle_image_edit(HistoryManager(), [a, b], INVERT, "Invert") + assert a.image_edits == (INVERT,) + assert b.image_edits == (INVERT,) + + +def test_one_undo_step_restores_everything(): + history = HistoryManager() + a, b = WorkPiece("a"), WorkPiece("b") + toggle_image_edit(history, [a, b], INVERT, "Invert") + history.undo() + assert a.image_edits == () + assert b.image_edits == () + history.redo() + assert a.image_edits == (INVERT,) + assert b.image_edits == (INVERT,) + + +def test_empty_selection_does_nothing(): + history = HistoryManager() + assert not toggle_image_edit(history, [], INVERT, "Invert") + assert not history.can_undo() diff --git a/rayforge/builtin_addons/rayforge-addon-image-editing/tests/test_ops.py b/rayforge/builtin_addons/rayforge-addon-image-editing/tests/test_ops.py new file mode 100644 index 000000000..7851b6296 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-image-editing/tests/test_ops.py @@ -0,0 +1,33 @@ +import numpy as np +import pyvips +from image_editing.ops import INVERT, InvertOp + +from rayforge.core.image_edit import image_edit_registry + + +def _image(*bands: int) -> pyvips.Image: + return pyvips.Image.new_from_array(np.array([[bands]], dtype=np.uint8)) + + +def _pixel(image: pyvips.Image) -> list[float]: + return list(image(0, 0)) + + +def test_invert_is_registered_under_its_stable_name(): + assert INVERT.op == "invert" + assert image_edit_registry.get("invert") is InvertOp + + +def test_invert_keeps_alpha(): + rgba = _image(10, 20, 30, 128).copy(interpretation="srgb") + assert _pixel(InvertOp.apply(rgba, {})) == [245, 235, 225, 128] + + +def test_invert_without_alpha(): + rgb = _image(0, 100, 255).copy(interpretation="srgb") + assert _pixel(InvertOp.apply(rgb, {})) == [255, 155, 0] + + +def test_invert_grayscale_with_alpha(): + gray_alpha = _image(40, 0).copy(interpretation="b-w") + assert _pixel(InvertOp.apply(gray_alpha, {})) == [215, 0] diff --git a/rayforge/builtin_addons/rayforge-addon-image-editing/tests/test_raster_equivalence.py b/rayforge/builtin_addons/rayforge-addon-image-editing/tests/test_raster_equivalence.py new file mode 100644 index 000000000..ce1914470 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-image-editing/tests/test_raster_equivalence.py @@ -0,0 +1,52 @@ +""" +The Invert edit replaces the Engrave step's old Invert setting; these +tests pin the behaviour that setting had. +""" + +import numpy as np +import pyvips +from image_editing.ops import InvertOp + +from rayforge.image.dither import DitherAlgorithm +from rayforge.image.util import normalize_to_rgba, vips_rgba_to_cairo_surface +from rayforge.pipeline.stage.assembler_helpers import ( + DepthMode, + preprocess_raster_image, +) + + +def _white_left_half_on_transparent() -> pyvips.Image: + pixels = np.zeros((10, 10, 4), dtype=np.uint8) + pixels[:, :5] = (255, 255, 255, 255) + return pyvips.Image.new_from_array(pixels).copy(interpretation="srgb") + + +def _surface(image: pyvips.Image): + return vips_rgba_to_cairo_surface(normalize_to_rgba(image)) + + +def test_inverted_dither_engraves_light_areas_only(): + inverted = InvertOp.apply(_white_left_half_on_transparent(), {}) + image, _ = preprocess_raster_image( + _surface(inverted), + mode=DepthMode.DITHER, + auto_levels=False, + dither_algorithm=DitherAlgorithm.BAYER2, + laser_spot_x_mm=0.1, + pixels_per_mm_x=10.0, + ) + assert image is not None + assert np.all(image[:, :5] == 1) + assert np.all(image[:, 5:] == 0) + + +def test_inverted_power_modulation_flips_visible_pixels_only(): + original = _white_left_half_on_transparent() + gray, alpha = preprocess_raster_image( + _surface(InvertOp.apply(original, {})), + mode=DepthMode.POWER_MODULATION, + auto_levels=False, + ) + assert gray is not None and alpha is not None + assert np.all(gray[:, :5] == 0) + assert np.all(alpha[:, 5:] == 0) diff --git a/website/docs/addons/image-editing.md b/website/docs/addons/image-editing.md new file mode 100644 index 000000000..cd006f4b1 --- /dev/null +++ b/website/docs/addons/image-editing.md @@ -0,0 +1,37 @@ +--- +description: + "Edit the image of a workpiece without changing the imported file, for example to invert it before + engraving or tracing." +--- + +# Image Editing + +The Image Editing addon changes how a workpiece's image looks without touching the imported file. +Each edit is stored with the workpiece, so you can undo it, remove it later, or place the same file +several times with different edits. + +The canvas, the 3D preview and every operation use the edited image. What you see on the canvas is +what the laser engraves. + +## Invert Image + +Select one or more workpieces and choose **Tools - Invert Image**. Light areas become dark and dark +areas become light; transparent areas stay transparent, so the background is still not engraved. + +Choose it again to remove the inversion. With a mixed selection, the workpieces that are not +inverted yet get inverted, so the whole selection ends up the same. + +Use it for lithophanes and embossing in an Engrave operation, where light areas should be engraved +deep, or to engrave light artwork on a dark background. + +## Older Projects + +Before this addon, Invert was a setting of the Engrave operation. When you open an older project +that used it, Rayforge moves the setting to the workpieces of that layer, so the output stays the +same. + +## When the Addon Is Disabled + +A project keeps its image edits when the addon is disabled or not installed. Rayforge tells you +that the project uses features that are not available and shows the images without those edits +until the addon is enabled again. diff --git a/website/sidebars.js b/website/sidebars.js index a293e104e..e3083eb15 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -117,6 +117,7 @@ module.exports = { items: [ 'addons/ai-workpiece-generator', 'addons/deepnest', + 'addons/image-editing', 'addons/print-and-cut', 'addons/projector-mode', 'addons/smart-stock', From 9ddb7c4db76a35fc3248b16b4888a0fd7755e2ac Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Mon, 12 Oct 2026 18:45:14 +0200 Subject: [PATCH 059/104] refactor(laser): move Engrave's Invert to the Invert image edit The Engrave step no longer has an Invert setting, and the raster preprocessing no longer knows about inversion. Projects saved with Invert on an Engrave step get the Invert edit on the workpieces of that layer when they are opened. Refs #515 --- CHANGELOG.md | 9 +++ .../laser_essentials/steps/raster_step.py | 32 +++++---- .../widgets/levels_adapter.py | 22 ++---- .../laser_essentials/widgets/raster_page.py | 28 +++----- .../widgets/raster_power_widget.py | 69 ++++++++----------- .../tests/steps/test_raster_step.py | 63 ++++++++++++++++- .../tests/steps/test_recipe_keys.py | 1 - .../tests/ui_gtk/test_laser_pages.py | 21 ++---- rayforge/pipeline/stage/assembler_helpers.py | 21 +----- .../pipeline/stage/test_assembler_helpers.py | 62 ----------------- tests/pipeline/test_intent_builder.py | 26 ++++++- website/docs/features/operations/engrave.md | 18 +++-- 12 files changed, 171 insertions(+), 201 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 618e33fde..bb13d2d03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,9 +51,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 back to the network - Linux: official AppImage builds are attached to GitHub releases - Windows: Publish Rayforge through WinGet ("winget install rayforge") +- Image Editing addon: non-destructive image edits stored per + workpiece and applied wherever its image is used (canvas, 3D preview, + operations). The first edit is Tools > Invert Image (#515) +- Addons: the `register_image_edits` hook and image edit registry + (plugin API 22) let addons provide their own image edits ### Changed +- Engrave: Invert moved from the operation to the workpiece, as the + Invert Image edit. Older projects are converted when they are opened, + so their output stays the same (#515) + - Machines: the GRBL Serial driver now runs its entire protocol stack (handshake, character-counting flow control, streaming, stall detection and deadlock recovery) in Rust through the raydriver diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/steps/raster_step.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/steps/raster_step.py index 1e1fced13..ada626486 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/steps/raster_step.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/steps/raster_step.py @@ -16,6 +16,7 @@ from raygeo.ops.part.image_source import WholeImageSource from rayforge.core.capability import MachineCapability +from rayforge.core.image_edit import ImageEditStep from rayforge.core.step import legacy_producer_params from rayforge.core.varset import ( AngleVar, @@ -229,14 +230,6 @@ def uses_levels(v): max_val=5.0, digits=3, ), - BoolVar( - key="invert", - label=_("Invert"), - description=_( - "Engrave white areas instead of black areas" - ), - default=False, - ), IntVar( key="num_depth_levels", label=_("Number of Depth Levels"), @@ -335,7 +328,7 @@ def __init__(self, name: str | None = None, typelabel: str | None = None): self.power = 0.2 self.scan_angle = 0.0 self.depth_mode = "POWER_MODULATION" - self.invert = False + self._legacy_invert = False self.auto_levels = True self.black_point = 0 self.white_point = 255 @@ -530,7 +523,6 @@ def get_cache_params(self) -> dict: params = super().get_cache_params() params.update( { - "invert": self.invert, "auto_levels": self.auto_levels, "black_point": self.black_point, "white_point": self.white_point, @@ -547,11 +539,21 @@ def get_cache_params(self) -> dict: ) return params + def pop_legacy_image_edits(self) -> list[ImageEditStep]: + """ + Older files stored Invert on the step. It is now the "invert" + image edit on the workpieces, which reproduces it for the + visible pixels. + """ + if not self._legacy_invert: + return [] + self._legacy_invert = False + return [_LEGACY_INVERT] + def to_dict(self) -> dict: result = super().to_dict() result["scan_angle"] = self.scan_angle result["depth_mode"] = self.depth_mode - result["invert"] = self.invert result["auto_levels"] = self.auto_levels result["black_point"] = self.black_point result["white_point"] = self.white_point @@ -594,7 +596,9 @@ def from_dict(cls, data: dict) -> EngraveStep: step.depth_mode = data.get( "depth_mode", legacy.get("depth_mode", "POWER_MODULATION") ) - step.invert = data.get("invert", legacy.get("invert", False)) + step._legacy_invert = bool( + data.get("invert", legacy.get("invert", False)) + ) step.auto_levels = data.get( "auto_levels", legacy.get("auto_levels", True) ) @@ -831,13 +835,11 @@ def _build_raster_part( computed_auto_levels = compute_raster_auto_levels( workpiece, (px_per_mm_x, px_per_mm_y), - invert=step.invert, ) image, alpha = preprocess_raster_image( surface, mode=depth_mode, - invert=step.invert, auto_levels=step.auto_levels, computed_auto_levels=computed_auto_levels, black_point=step.black_point, @@ -864,3 +866,5 @@ def _build_raster_part( MAX_RASTER_RENDER_PIXELS = 16 * 1024 * 1024 + +_LEGACY_INVERT = ImageEditStep("invert") diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/levels_adapter.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/levels_adapter.py index fbc97648c..4d6ed82a5 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/levels_adapter.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/levels_adapter.py @@ -33,9 +33,9 @@ class LevelsAdapter(RowAdapter): mode. The adapter also owns the histogram data: the dialog points it at - the step via :meth:`set_histogram_source` and it (re)computes the - histogram from the step's layer whenever ``invert`` changes (the - value arrives through :meth:`update_from_values`). + the step via :meth:`set_histogram_source` and it computes the + histogram from the step's layer, as the workpieces render with their + image edits applied. It edits two step attributes at once: @@ -58,8 +58,6 @@ def __init__( self._row = row self.preview = preview self._step: Any = None - self._last_invert: bool | None = None - self._histogram_ready = False preview.black_point_changed.connect( lambda s, **kw: self.changed.send(self), weak=False, @@ -99,8 +97,6 @@ def compute_histogram(self) -> None: step = self._step if step is None: return - invert = bool(self._last_invert or False) - layer = step.layer if not layer: self.preview.update_histogram(None) @@ -137,7 +133,7 @@ def compute_histogram(self) -> None: if not surface: continue - gray_values = get_visible_grayscale_values(surface, invert) + gray_values = get_visible_grayscale_values(surface) if gray_values.size > 0: all_gray_values.append(gray_values) @@ -153,7 +149,6 @@ def compute_histogram(self) -> None: auto_black, auto_white = compute_auto_levels(combined_gray) self.preview.set_auto_points(auto_black, auto_white) - self._histogram_ready = True def get_value(self) -> Any | None: return self.preview.black_point @@ -180,19 +175,12 @@ def update_from_var(self, var: Var): self._row.set_subtitle(escape_title(var.description)) def update_from_values(self, values: dict[str, Any]) -> None: - """Sync the preview's auto mode from the sibling auto_levels, - and recompute the histogram when invert changes.""" + """Sync the preview's auto mode from the sibling auto_levels.""" auto_mode = bool(values.get("auto_levels", True)) if auto_mode != self.preview.auto_mode: self.preview.auto_mode = auto_mode self._update_subtitle() - invert = values.get("invert") - if invert is not None and invert != self._last_invert: - self._last_invert = bool(invert) - if self._histogram_ready: - self.compute_histogram() - def _update_subtitle(self): if self.preview.auto_mode: self._row.set_subtitle( diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/raster_page.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/raster_page.py index 124671f8a..62b289e6e 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/raster_page.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/raster_page.py @@ -29,7 +29,6 @@ "sample_interval_mm", "dot_width_correction_mm", "bidir_x_offset_mm", - "invert", "num_depth_levels", "z_step_down", "angle_increment", @@ -79,18 +78,16 @@ def __init__( widget_cls=RasterPowerWidget, ) - # The Power section's visible_when predicates and histogram - # depend on depth_mode and invert, whose rows live in the - # Engrave section. Feed them in as context; the adapters fire - # changed synchronously on user interaction and on resync. + # The Power section's visible_when predicates depend on + # depth_mode, whose row lives in the Engrave section. Feed it in + # as context; the adapter fires changed synchronously on user + # interaction and on resync. self._sync_power_context() - for key in ("depth_mode", "invert"): - adapter = self.engrave_widget.adapter_for(key) - if adapter is not None: - adapter.changed.connect( - lambda sender, k=key: self._sync_power_context(), - weak=False, - ) + adapter = self.engrave_widget.adapter_for("depth_mode") + if adapter is not None: + adapter.changed.connect( + lambda sender: self._sync_power_context(), weak=False + ) levels = self.power_widget.adapter_for("black_point") if isinstance(levels, LevelsAdapter): @@ -120,13 +117,10 @@ def _push_head_defaults(self): self.engrave_widget.set_values(values) def _sync_power_context(self): - """Feed the current depth_mode/invert into the Power section.""" + """Feed the current depth_mode into the Power section.""" values = self.engrave_widget.get_values() self.power_widget.set_context_values( - { - "depth_mode": values.get("depth_mode"), - "invert": values.get("invert"), - } + {"depth_mode": values.get("depth_mode")} ) def _on_varset_data_changed(self, widget, key): diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/raster_power_widget.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/raster_power_widget.py index 47740906d..8717e853e 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/raster_power_widget.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/raster_power_widget.py @@ -1,10 +1,7 @@ """Custom varset widget for the raster "Power" section. -Owns the invert-dependent min/max power labels: the min/max rows -describe the lightest/darkest areas, and which is which flips with the -``invert`` setting. The invert row lives in the Engrave section, so -its value arrives here through :meth:`set_context_values`; this widget -re-titles its rows whenever visibility is re-evaluated. +Titles the min/max power rows after the lightest and darkest areas of +the image, and hides the section when no row is visible. """ from gettext import gettext as _ @@ -19,53 +16,41 @@ class RasterPowerWidget(VarSetWidget): """The raster dialog's "Power" section. - Re-titles the min/max power rows based on the ``invert`` context - value. Visibility itself is handled by the base machinery, which - calls :meth:`_update_visibility` after populate, value changes and - context pushes. + Visibility itself is handled by the base machinery, which calls + :meth:`_update_visibility` after populate, value changes and context + pushes. """ def _update_visibility(self): super()._update_visibility() - self._update_power_labels( - bool(self._context_values.get("invert", False)) - ) + self._update_power_labels() # Modes without power modulation or levels (constant power) # leave every row hidden; hide the empty section entirely. self.set_visible( any(row.get_visible() for row, _var in self.widget_map.values()) ) - def _update_power_labels(self, invert: bool): - """Update min/max power labels based on invert setting.""" - lightest_subtitle = _( - "Power for lightest areas, as a percentage of the step's " - "main power" + def _update_power_labels(self): + """Title the min/max rows after the areas they drive.""" + self._set_row_text( + "min_power_level", + _("Min Power (White)"), + _( + "Power for lightest areas, as a percentage of the step's " + "main power" + ), ) - darkest_subtitle = _( - "Power for darkest areas, as a percentage of the step's main power" + self._set_row_text( + "max_power_level", + _("Max Power (Black)"), + _( + "Power for darkest areas, as a percentage of the step's " + "main power" + ), ) - if invert: - min_title = _("Min Power (Black)") - min_subtitle = darkest_subtitle - max_title = _("Max Power (White)") - max_subtitle = lightest_subtitle - else: - min_title = _("Min Power (White)") - min_subtitle = lightest_subtitle - max_title = _("Max Power (Black)") - max_subtitle = darkest_subtitle - - min_row = self.row_for("min_power_level") - if min_row is not None: - min_row.set_title(escape_title(min_title)) - cast(Adw.ActionRow, min_row).set_subtitle( - escape_title(min_subtitle) - ) - max_row = self.row_for("max_power_level") - if max_row is not None: - max_row.set_title(escape_title(max_title)) - cast(Adw.ActionRow, max_row).set_subtitle( - escape_title(max_subtitle) - ) + def _set_row_text(self, key: str, title: str, subtitle: str): + row = self.row_for(key) + if row is not None: + row.set_title(escape_title(title)) + cast(Adw.ActionRow, row).set_subtitle(escape_title(subtitle)) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_raster_step.py b/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_raster_step.py index 8c7591621..2e7aef827 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_raster_step.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_raster_step.py @@ -10,6 +10,8 @@ from raygeo.ops.part import Part from raygeo.ops.types import CommandCategory, CommandType +from rayforge.core.doc import Doc +from rayforge.core.image_edit import ImageEditStep from rayforge.core.step_registry import step_registry from rayforge.core.varset import LabeledChoiceVar from rayforge.core.workpiece import WorkPiece @@ -17,6 +19,8 @@ from rayforge.pipeline.transformer import OpsTransformer from rayforge.pipeline.transformer.registry import transformer_registry +LEGACY_INVERT = ImageEditStep("invert") + @pytest.fixture def mock_context(): @@ -204,7 +208,7 @@ def test_from_dict_migrates_legacy_opsproducer_params(self): assert restored.num_depth_levels == 3 assert restored.num_power_levels == 10 assert restored.z_step_down == 0.5 - assert restored.invert is True + assert restored.pop_legacy_image_edits() == [LEGACY_INVERT] assert restored.auto_levels is False assert restored.black_point == 20 assert restored.white_point == 200 @@ -680,3 +684,60 @@ def test_build_raster_part_forwards_dither_options(self, machine): assert kwargs["halftone_angle"] == 12.0 assert kwargs["pixels_per_mm_x"] == pytest.approx(20.0) assert kwargs["pixels_per_mm_y"] == pytest.approx(10.0) + + +def _engrave_step_dicts(data) -> list[dict]: + found = [] + if isinstance(data, dict): + if data.get("step_type") == "EngraveStep": + found.append(data) + for value in data.values(): + found += _engrave_step_dicts(value) + elif isinstance(data, list): + for value in data: + found += _engrave_step_dicts(value) + return found + + +class TestLegacyInvert: + """Invert moved from the step to the workpieces' image edits.""" + + def _saved(self, invert: bool) -> dict: + data = EngraveStep(name="Engrave").to_dict() + data["invert"] = invert + return data + + def test_invert_is_no_longer_a_step_setting(self): + step = EngraveStep() + assert "invert" not in step.to_dict() + assert "invert" not in step.get_cache_params() + assert not hasattr(step, "invert") + + def test_saved_invert_is_handed_over_once(self): + step = EngraveStep.from_dict(self._saved(True)) + assert "invert" not in step.extra + assert step.pop_legacy_image_edits() == [LEGACY_INVERT] + assert step.pop_legacy_image_edits() == [] + + def test_saved_invert_false_hands_over_nothing(self): + step = EngraveStep.from_dict(self._saved(False)) + assert step.pop_legacy_image_edits() == [] + + def test_saved_invert_is_not_written_back(self): + step = EngraveStep.from_dict(self._saved(True)) + assert "invert" not in step.to_dict() + + def test_loading_a_document_moves_invert_to_the_workpieces(self): + doc = Doc() + wp = WorkPiece("photo") + doc.active_layer.add_child(wp) + doc.active_layer.workflow.add_step(EngraveStep(name="Engrave")) + data = doc.to_dict() + _engrave_step_dicts(data)[0]["invert"] = True + + restored = Doc.from_dict(data) + + (restored_wp,) = restored.all_workpieces + assert restored_wp.image_edits == (LEGACY_INVERT,) + again = Doc.from_dict(restored.to_dict()) + assert again.all_workpieces[0].image_edits == (LEGACY_INVERT,) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_recipe_keys.py b/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_recipe_keys.py index fa4ce197b..6f09d5d92 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_recipe_keys.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_recipe_keys.py @@ -48,7 +48,6 @@ def test_engrave_step_extends_laser(self): for key in ( "scan_angle", "depth_mode", - "invert", "min_power_level", "max_power_level", ): 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 d07e0c16e..be9b541a8 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 @@ -684,29 +684,20 @@ def test_raster_page_power_section_levels_visible_in_dither( @pytest.mark.ui -def test_raster_power_widget_flips_labels_on_invert( +def test_raster_power_widget_titles_rows_by_area( editor, laser_machine, ui_context ): - """The Power widget re-titles min/max rows when invert changes.""" + """The min/max rows are titled after the areas they drive, and + the Engrave section no longer offers Invert.""" step_cls = step_registry.get("EngraveStep") assert step_cls is not None step = cast(EngraveStep, step_cls.create(ui_context)) page = RasterSettingsPage(editor, step) assert isinstance(page.power_widget, RasterPowerWidget) - - min_row = _row(page, "min_power_level") - max_row = _row(page, "max_power_level") - assert min_row.get_title() == "Min Power (White)" - assert max_row.get_title() == "Max Power (Black)" - - invert_adapter = page.engrave_widget.adapter_for("invert") - assert invert_adapter is not None - invert_adapter.set_value(True) - page._sync_power_context() - - assert min_row.get_title() == "Min Power (Black)" - assert max_row.get_title() == "Max Power (White)" + assert _row(page, "min_power_level").get_title() == "Min Power (White)" + assert _row(page, "max_power_level").get_title() == "Max Power (Black)" + assert page.engrave_widget.adapter_for("invert") is None @pytest.mark.ui diff --git a/rayforge/pipeline/stage/assembler_helpers.py b/rayforge/pipeline/stage/assembler_helpers.py index 73629a4d6..ffdcbe630 100644 --- a/rayforge/pipeline/stage/assembler_helpers.py +++ b/rayforge/pipeline/stage/assembler_helpers.py @@ -292,7 +292,6 @@ def preprocess_raster_image( surface: cairo.ImageSurface, *, mode: DepthMode, - invert: bool = False, auto_levels: bool = True, computed_auto_levels: tuple[int, int] | None = None, black_point: int = 0, @@ -316,7 +315,6 @@ def preprocess_raster_image( Args: surface: The rendered Cairo ARGB32 surface. mode: The rasterisation depth mode. - invert: If True, invert grayscale before further processing. auto_levels: If True, auto-compute black/white points from the image histogram. computed_auto_levels: Pre-computed ``(black, white)`` points @@ -350,9 +348,6 @@ def preprocess_raster_image( """ if mode in (DepthMode.MULTI_PASS, DepthMode.POWER_MODULATION): gray_image, alpha = surface_to_grayscale(surface) - if invert: - alpha_mask = alpha > 0 - gray_image[alpha_mask] = 255 - gray_image[alpha_mask] gray_image = _apply_raster_levels( gray_image, alpha, @@ -370,9 +365,6 @@ def preprocess_raster_image( ) algo = dither_algorithm or DitherAlgorithm.FLOYD_STEINBERG gray_image, alpha = surface_to_grayscale(surface) - if invert: - alpha_mask = alpha > 0 - gray_image[alpha_mask] = 255 - gray_image[alpha_mask] gray_image = _apply_raster_levels( gray_image, alpha, @@ -396,11 +388,7 @@ def preprocess_raster_image( return image, None if mode == DepthMode.CONSTANT_POWER: - image = surface_to_binary( - surface, - threshold=threshold, - invert=invert, - ) + image = surface_to_binary(surface, threshold=threshold) return image, None return None, None @@ -436,7 +424,6 @@ def compute_raster_auto_levels( workpiece: WorkPiece, pixels_per_mm: tuple[float, float], *, - invert: bool = False, max_preview_pixels: int = 512, ) -> tuple[int, int] | None: """Compute auto-levels from a low-resolution preview render. @@ -452,8 +439,6 @@ def compute_raster_auto_levels( Args: workpiece: The WorkPiece to render a preview of. pixels_per_mm: The ``(x, y)`` resolution of the full render. - invert: If True, invert the grayscale before computing - levels. max_preview_pixels: Maximum preview dimension in pixels. Returns: @@ -478,10 +463,6 @@ def compute_raster_auto_levels( gray_image, alpha = surface_to_grayscale(surface) - if invert: - alpha_mask = alpha > 0 - gray_image[alpha_mask] = 255 - gray_image[alpha_mask] - surface.flush() return compute_auto_levels(gray_image[alpha > 0]) diff --git a/tests/pipeline/stage/test_assembler_helpers.py b/tests/pipeline/stage/test_assembler_helpers.py index d40e85252..cae47115b 100644 --- a/tests/pipeline/stage/test_assembler_helpers.py +++ b/tests/pipeline/stage/test_assembler_helpers.py @@ -286,28 +286,6 @@ def test_dither_auto_levels_stretch_contrast(self): assert np.sum(image_auto_levels) == 5000 assert np.sum(image_auto_levels) != np.sum(image_no_levels) - def test_dither_invert_engraves_light_areas_only(self): - """Inverted dithering engraves light content but keeps the - transparent background clear.""" - surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, 10, 10) - ctx = cairo.Context(surface) - ctx.set_source_rgba(1, 1, 1, 1) - ctx.rectangle(0, 0, 5, 10) - ctx.fill() - image, _ = preprocess_raster_image( - surface, - mode=DepthMode.DITHER, - invert=True, - auto_levels=False, - dither_algorithm=DitherAlgorithm.BAYER2, - laser_spot_x_mm=0.1, - pixels_per_mm_x=10.0, - ) - - assert image is not None - assert np.all(image[:, :5] == 1) - assert np.all(image[:, 5:] == 0) - def _gradient_surface(self, width=120, height=60): surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height) ctx = cairo.Context(surface) @@ -391,25 +369,6 @@ def test_mask_scan_returns_binary_no_alpha(self): assert alpha is None assert set(np.unique(image)).issubset({0, 1}) - def test_invert_flips_grayscale(self): - surface = self._black_surface() - image_normal, _ = preprocess_raster_image( - surface, - mode=DepthMode.POWER_MODULATION, - invert=False, - auto_levels=False, - ) - image_inverted, _ = preprocess_raster_image( - surface, - mode=DepthMode.POWER_MODULATION, - invert=True, - auto_levels=False, - ) - - assert image_normal is not None - assert image_inverted is not None - assert np.mean(image_inverted) > np.mean(image_normal) - def test_auto_levels_normalizes_grayscale(self): surface = self._gray_surface() image_no_levels, _ = preprocess_raster_image( @@ -494,27 +453,6 @@ def test_returns_none_when_render_fails(self): result = compute_raster_auto_levels(mock_wp, (10.0, 10.0)) assert result is None - def test_invert_affects_levels(self): - surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, 10, 10) - ctx = cairo.Context(surface) - ctx.set_source_rgb(0.2, 0.2, 0.2) - ctx.paint() - - mock_wp = MagicMock() - mock_wp.size = (10.0, 10.0) - mock_wp.render_to_pixels.return_value = surface - - result_normal = compute_raster_auto_levels( - mock_wp, (10.0, 10.0), invert=False - ) - result_inverted = compute_raster_auto_levels( - mock_wp, (10.0, 10.0), invert=True - ) - - assert result_normal is not None - assert result_inverted is not None - assert result_normal != result_inverted - def test_preview_size_limited_by_max_preview_pixels(self): surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, 100, 100) ctx = cairo.Context(surface) diff --git a/tests/pipeline/test_intent_builder.py b/tests/pipeline/test_intent_builder.py index f0f5d44b8..7fd3b4c18 100644 --- a/tests/pipeline/test_intent_builder.py +++ b/tests/pipeline/test_intent_builder.py @@ -37,6 +37,7 @@ import rayforge.machine.driver as driver_module from rayforge.core.doc import Doc +from rayforge.core.image_edit import ImageEditStep from rayforge.core.layer import Layer from rayforge.core.step import Step from rayforge.core.stock import StockItem @@ -1455,7 +1456,6 @@ def test_raster_step_is_position_sensitive(engrave_step_class): @pytest.mark.parametrize( "attr,value", [ - ("invert", True), ("auto_levels", False), ("black_point", 80), ("white_point", 200), @@ -1472,7 +1472,7 @@ def test_raster_compute_token_changes_on_image_preprocess_params( """Changing any raster image-preprocessing setting must invalidate the workpiece compute cache. - The levels (black/white points), inversion, threshold and dither + The levels (black/white points), threshold and dither are baked into the preprocessed image the assembler consumes, so a token that omits them would replay the exact same ops regardless of the brightness settings. @@ -1496,6 +1496,28 @@ def test_raster_compute_token_changes_on_image_preprocess_params( assert before_t != after_t +def test_compute_token_changes_on_workpiece_image_edits( + engrave_step_class, test_machine_and_config +): + """Image edits live on the workpiece and are baked into the image + every raster step consumes, so changing them must invalidate the + workpiece compute cache.""" + machine, context = test_machine_and_config + step = engrave_step_class.create(context, name="engrave") + wp = WorkPiece(name="wp") + wp.set_size(20.0, 20.0) + doc = _make_doc(step, wp) + + before = IntentBuilder(machine=machine).build(doc) + wpk = workpiece_key(wp.uid, step.uid) + before_t = next(n.version_token for n in before if n.key == wpk) + + wp.image_edits = [ImageEditStep("invert")] + after = IntentBuilder(machine=machine).build(doc) + after_t = next(n.version_token for n in after if n.key == wpk) + assert before_t != after_t + + # ---------------------------------------------------------------------- # Stock resolution # ---------------------------------------------------------------------- diff --git a/website/docs/features/operations/engrave.md b/website/docs/features/operations/engrave.md index f026b9238..eb8c7c198 100644 --- a/website/docs/features/operations/engrave.md +++ b/website/docs/features/operations/engrave.md @@ -182,12 +182,11 @@ described below. | 10% | 60% | Medium depth, safe | | 20% | 80% | Deep, dramatic relief | -**Invert:** +**Inverting the image:** -- **Off** (default): White = shallow, Black = deep -- **On**: White = deep, Black = shallow - -Use invert for lithophanes (light areas should be thin) or embossing (raised areas). +Black engraves deep and white stays shallow. To swap them, for lithophanes (light areas should be +thin) or embossing (raised areas), select the workpiece and use **Tools - Invert Image**. See +[Image Editing](../../addons/image-editing.md). **Brightness Range:** @@ -280,12 +279,11 @@ at least a few laser spot widths. - Creates crosshatch-like 3D effect - Typical: 0-45 degrees -**Invert:** - -- **Enabled:** White = deep, Black = shallow -- **Disabled:** Black = deep, White = shallow +**Inverting the image:** -Use invert for lithophanes (light areas should be thin) or embossing (raised areas). +Black engraves deep and white stays shallow. To swap them, for lithophanes (light areas should be +thin) or embossing (raised areas), select the workpiece and use **Tools - Invert Image**. See +[Image Editing](../../addons/image-editing.md). ### Scan Pattern From a3ee5d0f456eef0dec003fc3a2a951d33fc5c6ee Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Mon, 12 Oct 2026 19:11:19 +0200 Subject: [PATCH 060/104] test: narrow the optional workflow before adding steps Refs #515 --- .../rayforge-addon-laser/tests/steps/test_raster_step.py | 4 +++- tests/core/test_doc_image_edits.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_raster_step.py b/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_raster_step.py index 2e7aef827..8c3d195d9 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_raster_step.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_raster_step.py @@ -731,7 +731,9 @@ def test_loading_a_document_moves_invert_to_the_workpieces(self): doc = Doc() wp = WorkPiece("photo") doc.active_layer.add_child(wp) - doc.active_layer.workflow.add_step(EngraveStep(name="Engrave")) + workflow = doc.active_layer.workflow + assert workflow is not None + workflow.add_step(EngraveStep(name="Engrave")) data = doc.to_dict() _engrave_step_dicts(data)[0]["invert"] = True diff --git a/tests/core/test_doc_image_edits.py b/tests/core/test_doc_image_edits.py index ba6483acb..714e4be85 100644 --- a/tests/core/test_doc_image_edits.py +++ b/tests/core/test_doc_image_edits.py @@ -21,7 +21,9 @@ def _doc_with(step: Step, *workpieces: WorkPiece) -> Doc: doc = Doc() for wp in workpieces: doc.active_layer.add_child(wp) - doc.active_layer.workflow.add_step(step) + workflow = doc.active_layer.workflow + assert workflow is not None + workflow.add_step(step) return doc From 898b34aca5909404f9b9ba5e3b2fdc99abf5070b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:46:52 +0000 Subject: [PATCH 061/104] chore(deps): bump prefix-dev/setup-pixi from 0.10.2 to 0.11.0 Bumps [prefix-dev/setup-pixi](https://github.com/prefix-dev/setup-pixi) from 0.10.2 to 0.11.0. - [Release notes](https://github.com/prefix-dev/setup-pixi/releases) - [Commits](https://github.com/prefix-dev/setup-pixi/compare/v0.10.2...v0.11.0) --- updated-dependencies: - dependency-name: prefix-dev/setup-pixi dependency-version: 0.11.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/build-appimage.yml | 2 +- .github/workflows/lint-test.yml | 8 ++++---- .github/workflows/pixi-dependabot.yml | 2 +- .github/workflows/publish-to-pypi.yml | 2 +- .github/workflows/publish-to-snap-store.yml | 2 +- .github/workflows/website.yml | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build-appimage.yml b/.github/workflows/build-appimage.yml index 713404f36..71648164e 100644 --- a/.github/workflows/build-appimage.yml +++ b/.github/workflows/build-appimage.yml @@ -46,7 +46,7 @@ jobs: echo "Version: $VERSION" - name: Install Pixi - uses: prefix-dev/setup-pixi@v0.10.2 + uses: prefix-dev/setup-pixi@v0.11.0 with: pixi-version: v0.68.1 frozen: true diff --git a/.github/workflows/lint-test.yml b/.github/workflows/lint-test.yml index 580ead6dc..fc257c223 100644 --- a/.github/workflows/lint-test.yml +++ b/.github/workflows/lint-test.yml @@ -22,7 +22,7 @@ jobs: fetch-tags: true - name: Install Pixi - uses: prefix-dev/setup-pixi@v0.10.2 + uses: prefix-dev/setup-pixi@v0.11.0 with: pixi-version: v0.68.1 frozen: true @@ -46,7 +46,7 @@ jobs: fetch-tags: true - name: Install Pixi - uses: prefix-dev/setup-pixi@v0.10.2 + uses: prefix-dev/setup-pixi@v0.11.0 with: pixi-version: v0.68.1 frozen: true @@ -70,7 +70,7 @@ jobs: fetch-tags: true - name: Install Pixi - uses: prefix-dev/setup-pixi@v0.10.2 + uses: prefix-dev/setup-pixi@v0.11.0 with: pixi-version: v0.68.1 frozen: true @@ -94,7 +94,7 @@ jobs: fetch-tags: true - name: Install Pixi - uses: prefix-dev/setup-pixi@v0.10.2 + uses: prefix-dev/setup-pixi@v0.11.0 with: pixi-version: v0.68.1 frozen: true diff --git a/.github/workflows/pixi-dependabot.yml b/.github/workflows/pixi-dependabot.yml index 6a9c50af2..0c6593a47 100644 --- a/.github/workflows/pixi-dependabot.yml +++ b/.github/workflows/pixi-dependabot.yml @@ -17,7 +17,7 @@ jobs: uses: actions/checkout@v7 - name: Install Pixi - uses: prefix-dev/setup-pixi@v0.10.2 + uses: prefix-dev/setup-pixi@v0.11.0 with: pixi-version: v0.76.2 run-install: false diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml index 4d4461327..2c58b2b25 100644 --- a/.github/workflows/publish-to-pypi.yml +++ b/.github/workflows/publish-to-pypi.yml @@ -33,7 +33,7 @@ jobs: fetch-tags: true - name: Install Pixi - uses: prefix-dev/setup-pixi@v0.10.2 + uses: prefix-dev/setup-pixi@v0.11.0 with: pixi-version: latest frozen: true diff --git a/.github/workflows/publish-to-snap-store.yml b/.github/workflows/publish-to-snap-store.yml index 746890966..86e661624 100644 --- a/.github/workflows/publish-to-snap-store.yml +++ b/.github/workflows/publish-to-snap-store.yml @@ -30,7 +30,7 @@ jobs: fetch-tags: true - name: Install Pixi - uses: prefix-dev/setup-pixi@v0.10.2 + uses: prefix-dev/setup-pixi@v0.11.0 with: pixi-version: latest frozen: true diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml index e2fa400be..32de359fe 100644 --- a/.github/workflows/website.yml +++ b/.github/workflows/website.yml @@ -29,7 +29,7 @@ jobs: fetch-depth: 0 # Fetch all history for versioning - name: Set up Pixi - uses: prefix-dev/setup-pixi@v0.10.2 + uses: prefix-dev/setup-pixi@v0.11.0 with: pixi-version: v0.68.1 frozen: true From 9b5e99c9a1d3e024de8003acfbe772f8d515bd7a Mon Sep 17 00:00:00 2001 From: Samuel Abels Date: Mon, 12 Oct 2026 23:08:06 +0200 Subject: [PATCH 062/104] fix: attribute action extension handlers by identity; add enabled_when ActionExtensionRegistry keyed its addon map by handler function name, so addons registering same-named handlers (print-and-cut, sketcher) collided: disabling one left stale handlers behind or stripped the other addon's handlers. The map is now keyed by handler identity, and registering an already-registered handler is a no-op. The register_actions hook is now also invoked when an addon is enabled while the window exists, and action_registry.register() accepts an enabled_when predicate that ActionManager evaluates on every action state update. Addons can therefore create, place and enable/disable their actions in the register_actions hook alone, without action_extension_registry. Plugin API 22. --- CHANGELOG.md | 10 +- rayforge/core/hooks.py | 12 +- rayforge/ui_gtk/action_registry.py | 20 ++++ rayforge/ui_gtk/actions.py | 37 ++++-- rayforge/ui_gtk/mainwindow.py | 4 + tests/ui_gtk/test_action_registries.py | 155 +++++++++++++++++++++++++ 6 files changed, 226 insertions(+), 12 deletions(-) create mode 100644 tests/ui_gtk/test_action_registries.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 618e33fde..d50890289 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,11 +62,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 existing machine configurations continue to work unchanged. Machines saved while the driver carried its experimental name keep working through a compatibility alias +- Addon API 22: actions registered via the `register_actions` hook + accept an `enabled_when` predicate for their enabled state, and the + hook is invoked again when an addon is enabled while the window + exists, so addons no longer need `action_extension_registry` for + either - Updated translations ### Fixed -- Engrave: the step's Bidirectional Scan Offset had no effect since +- Addon API: disabling an addon did not reliably remove its + `action_extension_registry` handlers when several addons registered + same-named handlers, which could leave stale handlers behind or + strip other addons' handlers- Engrave: the step's Bidirectional Scan Offset had no effect since the pipeline moved to raygeo intents, because the value no longer reached the transformer; changing it now also refreshes the cached toolpath (#307) diff --git a/rayforge/core/hooks.py b/rayforge/core/hooks.py index 16abbab0f..880fffbb7 100644 --- a/rayforge/core/hooks.py +++ b/rayforge/core/hooks.py @@ -4,13 +4,23 @@ hookimpl = pluggy.HookimplMarker("rayforge") MINIMUM_API_VERSION = 16 -PLUGIN_API_VERSION = 21 +PLUGIN_API_VERSION = 22 """ API Changelog ============= +Version 22 +---------- +``action_registry.register()`` accepts an ``enabled_when`` predicate that +receives the ``ActionManager`` and is evaluated on every action state +update. Addons that used ``action_extension_registry`` only to enable and +disable their action can now register directly in the +``register_actions`` hook. That hook is also invoked again when an addon +is enabled while the window exists, so runtime-enabled addons no longer +need ``action_extension_registry`` to have their actions created. + Version 21 ---------- Renamed ``WorkOriginElement.set_coordinate_space(space)`` to diff --git a/rayforge/ui_gtk/action_registry.py b/rayforge/ui_gtk/action_registry.py index 3a508d5c7..82077a2c5 100644 --- a/rayforge/ui_gtk/action_registry.py +++ b/rayforge/ui_gtk/action_registry.py @@ -1,9 +1,14 @@ import logging +from collections.abc import Callable from dataclasses import dataclass +from typing import TYPE_CHECKING from blinker import Signal from gi.repository import Gio, Gtk +if TYPE_CHECKING: + from .actions import ActionManager + logger = logging.getLogger(__name__) @@ -29,6 +34,7 @@ class ActionInfo: addon_name: str | None = None menu: MenuPlacement | None = None toolbar: ToolbarPlacement | None = None + enabled_when: 'Callable[["ActionManager"], bool] | None' = None class ActionRegistry: @@ -64,6 +70,7 @@ def register( shortcut: str | None = None, menu: MenuPlacement | None = None, toolbar: ToolbarPlacement | None = None, + enabled_when: 'Callable[["ActionManager"], bool] | None' = None, ) -> None: """ Register an action with the window and track it by addon. @@ -78,6 +85,9 @@ def register( menu: Menu placement info if this action should appear in a menu. toolbar: Toolbar placement info if this action should appear in the toolbar. + enabled_when: Predicate receiving the ActionManager, + evaluated on every action state update to set whether + the action is enabled. """ if self._window is None: logger.warning("Cannot register action: window not set") @@ -102,6 +112,7 @@ def register( addon_name=addon_name or "", menu=menu, toolbar=toolbar, + enabled_when=enabled_when, ) self._actions[action_name] = info @@ -216,6 +227,15 @@ def get_toolbar_items(self, group: str) -> list[ActionInfo]: items, key=lambda x: x.toolbar.priority if x.toolbar else 100 ) + def get_all(self) -> list[ActionInfo]: + """ + Get all registered actions. + + Returns: + List of all ActionInfo objects. + """ + return list(self._actions.values()) + def get_all_with_shortcuts(self) -> list[ActionInfo]: """ Get all actions that have keyboard shortcuts defined. diff --git a/rayforge/ui_gtk/actions.py b/rayforge/ui_gtk/actions.py index 2b6528625..a3561cf4f 100644 --- a/rayforge/ui_gtk/actions.py +++ b/rayforge/ui_gtk/actions.py @@ -111,21 +111,23 @@ class ActionExtensionRegistry: def __init__(self): self._setup_handlers: list[ActionSetupHandler] = [] self._state_update_handlers: list[ActionStateUpdateHandler] = [] - self._setup_addon_map: dict[str, str] = {} - self._state_update_addon_map: dict[str, str] = {} + self._setup_addon_map: dict[int, str] = {} + self._state_update_addon_map: dict[int, str] = {} def register_setup(self, handler: ActionSetupHandler, addon_name: str): """Register a handler to be called during action setup.""" + if handler in self._setup_handlers: + return self._setup_handlers.append(handler) if addon_name: - self._setup_addon_map[handler.__name__] = addon_name + self._setup_addon_map[id(handler)] = addon_name logger.debug(f"Registered action setup handler: {handler.__name__}") def unregister_setup(self, handler: ActionSetupHandler) -> bool: """Unregister an action setup handler.""" try: self._setup_handlers.remove(handler) - self._setup_addon_map.pop(handler.__name__, None) + self._setup_addon_map.pop(id(handler), None) return True except ValueError: return False @@ -134,9 +136,11 @@ def register_state_update( self, handler: ActionStateUpdateHandler, addon_name: str ): """Register a handler to be called during action state updates.""" + if handler in self._state_update_handlers: + return self._state_update_handlers.append(handler) if addon_name: - self._state_update_addon_map[handler.__name__] = addon_name + self._state_update_addon_map[id(handler)] = addon_name logger.debug( f"Registered action state update handler: {handler.__name__}" ) @@ -147,7 +151,7 @@ def unregister_state_update( """Unregister an action state update handler.""" try: self._state_update_handlers.remove(handler) - self._state_update_addon_map.pop(handler.__name__, None) + self._state_update_addon_map.pop(id(handler), None) return True except ValueError: return False @@ -167,21 +171,21 @@ def unregister_all_from_addon(self, addon_name: str) -> int: setup_to_remove = [ h for h in self._setup_handlers - if self._setup_addon_map.get(h.__name__) == addon_name + if self._setup_addon_map.get(id(h)) == addon_name ] for h in setup_to_remove: self._setup_handlers.remove(h) - self._setup_addon_map.pop(h.__name__, None) + self._setup_addon_map.pop(id(h), None) count += len(setup_to_remove) state_to_remove = [ h for h in self._state_update_handlers - if self._state_update_addon_map.get(h.__name__) == addon_name + if self._state_update_addon_map.get(id(h)) == addon_name ] for h in state_to_remove: self._state_update_handlers.remove(h) - self._state_update_addon_map.pop(h.__name__, None) + self._state_update_addon_map.pop(id(h), None) count += len(state_to_remove) if count > 0: @@ -583,6 +587,19 @@ def update_action_states(self, *args, **kwargs): ) action_extension_registry.invoke_state_update_handlers(self) + self._update_predicated_actions() + + def _update_predicated_actions(self): + """Sets enabled state of actions registered with enabled_when.""" + for info in action_registry.get_all(): + if info.enabled_when is None: + continue + try: + info.action.set_enabled(info.enabled_when(self)) + except Exception: + logger.exception( + f"Error updating state of action '{info.action_name}'" + ) def on_add_stock(self, action, param): """Handler for the 'add-stock' action.""" diff --git a/rayforge/ui_gtk/mainwindow.py b/rayforge/ui_gtk/mainwindow.py index 44c5aed66..db1cadddc 100644 --- a/rayforge/ui_gtk/mainwindow.py +++ b/rayforge/ui_gtk/mainwindow.py @@ -973,6 +973,10 @@ def _on_job_started(self, sender): def _on_addon_state_changed(self, sender, addon_name): """Handle addon enable/disable to refresh action handlers.""" + if addon_name in sender.loaded_addons: + call_registration_hooks( + get_context().plugin_mgr, window_required=True + ) action_extension_registry.invoke_setup_handlers(self.action_manager) self.action_manager.update_action_states() diff --git a/tests/ui_gtk/test_action_registries.py b/tests/ui_gtk/test_action_registries.py new file mode 100644 index 000000000..1212c229d --- /dev/null +++ b/tests/ui_gtk/test_action_registries.py @@ -0,0 +1,155 @@ +# flake8: noqa: E402 +import os +import sys +from typing import cast + +import pytest + +if sys.platform.startswith("linux") and not os.environ.get("DISPLAY"): + pytest.skip( + "DISPLAY not set 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") +from gi.repository import Gio, Gtk + +from rayforge.ui_gtk.action_registry import ActionRegistry +from rayforge.ui_gtk.actions import ActionExtensionRegistry, ActionManager + +pytestmark = pytest.mark.ui + + +class StubWindow: + def __init__(self): + self.added: list[Gio.SimpleAction] = [] + self.removed: list[Gio.SimpleAction] = [] + + def add_action(self, action): + self.added.append(action) + + def remove_action(self, action): + self.removed.append(action) + + +def _make_handler(): + def _register_actions(action_manager): + pass + + return _register_actions + + +def _make_manager() -> ActionManager: + return ActionManager.__new__(ActionManager) + + +def test_unregister_all_from_addon_with_same_handler_names(): + registry = ActionExtensionRegistry() + sketcher_handler = _make_handler() + print_and_cut_handler = _make_handler() + registry.register_setup(sketcher_handler, "sketcher") + registry.register_setup(print_and_cut_handler, "print_and_cut") + + assert registry.unregister_all_from_addon("sketcher") == 1 + assert registry._setup_handlers == [print_and_cut_handler] + + assert registry.unregister_all_from_addon("print_and_cut") == 1 + assert registry._setup_handlers == [] + + +def test_register_setup_is_idempotent(): + registry = ActionExtensionRegistry() + handler = _make_handler() + registry.register_setup(handler, "addon") + registry.register_setup(handler, "addon") + + assert registry._setup_handlers == [handler] + assert registry.unregister_all_from_addon("addon") == 1 + + +def test_register_state_update_is_idempotent(): + registry = ActionExtensionRegistry() + + def _update_action_states(action_manager): + pass + + registry.register_state_update(_update_action_states, "addon") + registry.register_state_update(_update_action_states, "addon") + + assert registry._state_update_handlers == [_update_action_states] + + +def test_register_stores_enabled_when(): + registry = ActionRegistry() + registry.set_window(cast(Gtk.ApplicationWindow, StubWindow())) + action = Gio.SimpleAction.new("test-action", None) + + def enabled_when(action_manager): + return True + + registry.register( + action_name="test-action", + action=action, + addon_name="test", + enabled_when=enabled_when, + ) + + info = registry.get("test-action") + assert info is not None and info.enabled_when is enabled_when + registry.unregister("test-action") + + +def test_update_predicated_actions_evaluates_predicate(monkeypatch): + registry = ActionRegistry() + registry.set_window(cast(Gtk.ApplicationWindow, StubWindow())) + monkeypatch.setattr("rayforge.ui_gtk.actions.action_registry", registry) + action = Gio.SimpleAction.new("predicated-test-action", None) + action.set_enabled(False) + received = [] + + def enabled_when(action_manager): + received.append(action_manager) + return True + + registry.register( + action_name="predicated-test-action", + action=action, + addon_name="test", + enabled_when=enabled_when, + ) + + manager = _make_manager() + manager._update_predicated_actions() + assert action.get_enabled() is True + assert received == [manager] + + +def test_update_predicated_actions_isolates_failing_predicate(monkeypatch): + registry = ActionRegistry() + registry.set_window(cast(Gtk.ApplicationWindow, StubWindow())) + monkeypatch.setattr("rayforge.ui_gtk.actions.action_registry", registry) + bad = Gio.SimpleAction.new("predicated-bad-action", None) + good = Gio.SimpleAction.new("predicated-good-action", None) + good.set_enabled(False) + + def failing(action_manager): + raise RuntimeError("boom") + + registry.register( + action_name="predicated-bad-action", + action=bad, + addon_name="test", + enabled_when=failing, + ) + registry.register( + action_name="predicated-good-action", + action=good, + addon_name="test", + enabled_when=lambda am: True, + ) + + _make_manager()._update_predicated_actions() + assert good.get_enabled() is True From a5f923d0deda32dcd564417dbfa9c25f67ea9d06 Mon Sep 17 00:00:00 2001 From: Samuel Abels Date: Mon, 12 Oct 2026 23:48:04 +0200 Subject: [PATCH 063/104] refactor: register Invert Image via the enabled_when action API The addon now registers its action directly in the register_actions hook with an enabled_when predicate, instead of deferring through action_extension_registry. Plugin API is 23 after merging main's API 22. --- .../image_editing/frontend.py | 35 ++++++++----------- 1 file changed, 14 insertions(+), 21 deletions(-) diff --git a/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/frontend.py b/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/frontend.py index 89894f0ca..a1bb9a23c 100644 --- a/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/frontend.py +++ b/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/frontend.py @@ -6,25 +6,34 @@ """ from gettext import gettext as _ +from typing import TYPE_CHECKING from gi.repository import Gio from rayforge.core.hooks import hookimpl -from rayforge.ui_gtk.action_registry import MenuPlacement, action_registry -from rayforge.ui_gtk.actions import action_extension_registry +from rayforge.ui_gtk.action_registry import MenuPlacement from .commands import toggle_image_edit from .ops import INVERT +if TYPE_CHECKING: + from rayforge.ui_gtk.actions import ActionManager + ADDON_NAME = "image_editing" INVERT_ACTION = "invert-image" -def _register_actions(action_manager): +def _has_selection(action_manager: "ActionManager") -> bool: + return bool(action_manager.win.surface.get_selected_workpieces()) + + +@hookimpl +def register_actions(action_registry): + """Register actions with menu placement.""" action = Gio.SimpleAction.new(INVERT_ACTION, None) def on_activate(action, param): - window = action_manager.win + window = action_registry.window toggle_image_edit( window.doc_editor.history_manager, window.surface.get_selected_workpieces(), @@ -33,27 +42,11 @@ def on_activate(action, param): ) action.connect("activate", on_activate) - action_manager.win.add_action(action) - action_manager.actions[INVERT_ACTION] = action action_registry.register( action_name=INVERT_ACTION, action=action, addon_name=ADDON_NAME, label=_("Invert Image"), menu=MenuPlacement(menu_id="tools", priority=60), - ) - - -def _update_action_states(action_manager): - action = action_manager.actions.get(INVERT_ACTION) - if action: - selected = action_manager.win.surface.get_selected_workpieces() - action.set_enabled(bool(selected)) - - -@hookimpl -def register_actions(action_registry): - action_extension_registry.register_setup(_register_actions, ADDON_NAME) - action_extension_registry.register_state_update( - _update_action_states, ADDON_NAME + enabled_when=_has_selection, ) From aa9a6b2dba0175dee9bff616d0dda05ac9045b4e Mon Sep 17 00:00:00 2001 From: Samuel Abels Date: Tue, 13 Oct 2026 01:45:48 +0200 Subject: [PATCH 064/104] feat(screenshots): recapture the engrave step settings The Engrave step lost its Invert row to the new image edit registry, and the dither and post processing pages picked up the Serpentine and Crop to Stock rows from main. --- ...ttings-engrave-general-constant_power.webp | Bin 28736 -> 29048 bytes .../step-settings-engrave-general-dither.webp | Bin 28752 -> 29026 bytes ...p-settings-engrave-general-multi_pass.webp | Bin 28292 -> 27876 bytes ...tep-settings-engrave-general-variable.webp | Bin 29618 -> 29676 bytes .../step-settings-engrave-post.webp | Bin 15718 -> 17692 bytes 5 files changed, 0 insertions(+), 0 deletions(-) diff --git a/website/static/screenshots/step-settings-engrave-general-constant_power.webp b/website/static/screenshots/step-settings-engrave-general-constant_power.webp index 6f8b0a6f2ab6c345a52ebfdb131967f9aa025dfa..39a09a7f734a33c1ade09f21f9b559bcf6991b7e 100644 GIT binary patch literal 29048 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 Tg8wW=fL%fTPi_LM|2_T>Ay1a; literal 28736 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 diff --git a/website/static/screenshots/step-settings-engrave-general-dither.webp b/website/static/screenshots/step-settings-engrave-general-dither.webp index 5352aacf63b6ac154e3136934920ab37742ec309..657a1b18776d3d2040b68d7f21dac3c9a4b8cd4b 100644 GIT binary patch literal 29026 zcmV*BKyJTMNk&FmaR2~UMM6+kP&il$0000O0001o0sylE09H^qOnPtt056Ba>=0E8 zFpo?#lO5R+?erLqrE7VQ zgQQY~FaB>_#h$l{4s9Ns?-_^fkN#2N5=!|lO|Rf_A@LRmscayN4iGGUp}bKUI>wW)!{Uph$W4^Qf2OP7RnVFfHnc2=54>L2x z98<>33=!LZ0K@nDe!V}R?+?E9{eE`bzhmrhkGZq$?6hTeM}{)DrOA%lnlf`6mSi!z z3}cm!8A>f3wfShGgO02+PHSd$%-7~V?R9iF(P?yib-IkHM;Do)W%fbTBX#7-7A7id zJKK)D>d|HgWiHb~Wur=t-sUnKl^pe&k8xC{RY!)hCNj4fM`bQ^Yc?x0Q)x>boqlHf z_}a|4Q06kX%F!`&otQ=&jm+I=>r9Rq3MgL}ng!WQtCv zky^j7!`4??DnpxlgI15)G^+6S`1&Z^$lEIO&p6vV#)Zg?$MDPC*0!xgdo8LX^pK)x ziJa7RrU7?@THCg3WuLv)&oU8;p#o&IREff2qo#<|01hHaQlvO$X8M0W;9pKsplz#e z8y)1SB+JTsRQ*iqDA(@+*5bWv|GT-drTSwg@0@d3IqtnQ%R2|{oO8}$<*1!=&N=7p zJvryNbIyBm&Ut>n@Avn<%B7mn3%e0!qeDaMput!ZRY%LHn%SwCBO_0gXnB%RAIPX+ z1yit->0WG%p?%O`>cmY^o?vQn;l)k@H*A5>Bq8d^)WjC@(Gm?c$@oOVt0rk_Qd^WK zxr`@T6Pq$COJ^=>M{Dww7dl$olhjC)UL#F(of!%5Nh{QWKssn-swQ>wPHM#Cy}=Z$ zV1xU}!gbJ%(S&Y<^X z#<(Zg<~LCVo>06KSJ+WB#b7MsGl>;!s^+LMb5xCWR9{gOtv)g(Pcp?0#<(X6y3Xu% z(%RBQ^WvkjCN&M7EHuHwXp+$c58~}JO%^|+lM<#58lgTgE@~xH6T7k5p$mi#8ly>V zQGMhWb}X9(Zi6QqP3Vy(kKRdyfwXNy?fN^6B-^w?__mFQZ`;T}pQgatw$M~sS0fK7 zUYd^Iu@xRXFhp*&ZL6jf=65 z64O%DA4R>w`AugHdXy%qSt+0A@#Aa6B=H zyE)NuxtD9NRL?4JWHWm$%-N4W(L285G4{6h5uYZfT@0ugjYL1Y*uNV1QMWz*_$%`_ zCpySVvXye;*OA+e**7vr9&g`w)mLpF_Gxne)HHeQVnE#}B*^jkQ-7-c3*i>G;0F;m zCprRqmt^8s>D?}u+^j85^hC#4b9=Mz`>FO}pC<1GY4Wj)0WG7DAjju|1@>6hcA0&X`3H?p6rJ}UK);8-6L9TgoJRU8Q&F&UgvuyyM-8_wGBYPPo^ zCIi!r(TPE^j_!@zthLsDPW~a%L!pNN6-P`)sI)%)<-aAbX3I^6>q#cEGxaL6M`j(p zjk$omUG-7IwLD}ZbB1VU*M{7U?)=na=tNdAJ5>CL8{w_r{vHbS$vk2*NTuMCyWaZ& zw)C65jrs>>k8}@sJNawrbHrqr%96Ldn!p>~0qhW9f83t64sJiS&viuY{KKF}2agw? zC_D*>?()>(;Xs6y$Dj&araWwDxQ?TwXr0~l<<5;?2qtUQ_Sheo`|aBq({=<=^i;YwPUv9{Q1S zk4!;EMU?)qKjLlMEB~HDi3tiIkH-7Sy48;7M_T;PY?z&XeC!nW%oMB;L}gjyA*gj0 z=f^pIHNF=;n0)0Rw+uh>BQ3sP!!@(hsl`1s1>1_~&z2WcFSeMv*y*ihTlHED=l$^hXiB_`x!Z+qZyaazFQRKLgr3&ss41^hCf@vuvdP z53WLHC_dejd8`HucYc!nxDUi&u1G(ZgZJ2aUnqQWPJ}Q=#>+5|5j{(6+HJ^kV%uj?aA_oGjtxwe2 znjiiNR?cB~GVt8hD;CW=dFqNqIFpZz{xk93Mxac|GHzkwt<2-hTge-apm#a%M=F6# z->29IRTlTq`4IXJ@J;FKRv)p zBs}K4t)V?cUT5xg^VgUx?uo%G6)#3aAJ^ghf5he+5UxxN=0?6UH?r^mve*6F_t0hT zZF7Bfhtuk1EF4w~lu@LcV=` ztK!g^zS*d9_WW4^c-&fo7>-F(8`lq@B4Be32Zme`}`DX<`_H;cmb;Um5AqUJXwgd{FhteiljB$Um@);5;J4ajMtD|HVbMv`nlsDZIqOc z23Bo>r9Ted<_IO_{B0;SG?L16a%dNoO7oV-<}v0?gz%>GSo3!0zu~>Li65hp*M;8{ z9yw$J0av_G9*InUt?6SBX^V62iCWRcO%k=RG{E& ztnuzOvwAWc&ZEqa`S2k`sZ5)4zES+;e>stLMdtkO=Dp}}_U+-dM4@3O z&pyPEUi18z3-e#x|5@KaI$zw=_I}fwvUr629XDT?{AzFh&%TCa^ELz6H>N0s;^9(< zQ64<87%(v)Sh?n?{T8?7iofSjexeNI9-GxRC6DIEo^8tb^ zjIj?3#ozHnjII9!;=J~$#+YAi#a7=k82i{Tr}D);jc$GsMnS>H0AtMgeT!o=kTEte zA;Reed=#;djHo~4BZ6I*(u>7_>6WwcBR}94`I}}@fgH^^@_weOoua_h6ET9?7 zD-?eiIl0|(SFg~eOxqeMAlYf(>T8;9Kw0*!>A2MASsn| zrG58(ydg6sa9Q>}!MW0#jOvc;h3NTCS) zrtwe8c7uC<7V6!qXQhW2#p;D!(w7fZe4SzoyuDeL-B&S+bu zFWpO)2JGvRqB2|5<%!rriVE!GA%2!86ldA=^v@#XgNwCSd`n>OR7ndyWL9T)d{_^71u7@NTE;!vs*<*TCxu% z&i5jJ?+@3E&fxR>BZ7X*H$A`Q>TF6d`)2j(qj=O|-wN(#%I5lh1bUOk{pF3mm!3?+ zQ79DY>@uyivjaXw%nd8k`w*xf0S%DMz^%?x$%D7t+zB_lEb*!yxm*8G^2!lSjdbY=ge6m z!fcMVSqKZVj|nQb1-n&ZGmk)0^2*Pqgs^WDDnjjQY~1*MS;ez9-as>B?B!)ZPGS`O|32S`LJ%LPoic%VG+&V|tax8yY*C#Ie2g2UAT7^+NQL61*mV`l zv;UFzxfv1nqVlDC;p{Imk=&Aghvm%jo|mMl?0Mfje8)6&s5mg zHq25p&aVnVr78ZAz&@trH06qc3Pq#U=a;jvXz)9Ie5z?TK8Mfs9<&epk>BXB`|Qod zt4`(_x{puyptdR3bJ8c>^Bg(uIHke&qfp$7?Ws@%K9D`cC=}*L`BnN{Dwbcs;AgAW zney=pfO>3sg~FOGt9Vyp3-Qip{|GH_5EY`cK}X$D(f z@r>+{&!c#V*|Z)TdZ}X5&qC)5ds5JnJ<-O;l*#7x0&d5;?;MwJ-V!(AF<^EHKhedB z*I01RMrvbhxv{8-PP?6YK^yIp&;Ma|B(yvw_MDaM1|rTVYAk0xif zKAS_Ocw@uvIhS9hM;XP@Y}`rgKnukO6f68dHdi5qVjafTcWzMeJjRBzCFp!%PYQmh z(->oZ*}Tnw<}J_B+I&mB9T~7%>JkHj?Q1^C;x9zJ)`#?oemQW)i2X>O0TTn}IR)r%c{f=V^<=-)x5($A9AjD0kzGNsq2XuLoTc^yz~{ym89 z`Kuiu;CZNTVwdM;6rAIwK7Zr<6FgHZdR9LFsDDJEP(0(Uzdp=(4@y;R+&Nu-teInH zHH;hI50$~a&iM>$nJN^Y<>P<7s{VX`2bXc*&#;fDsmSL=zsUaA`=86N_pElneH>UE%J^lP|@@uG_*XsQCrBEn*|B+v8b@r*E|MTYttPPkS z#xtNH=!`k`Paw}VJDVRfN8txy><5X5q!3?Ovr7EP7AnmLAJ!{#pHDpCLcjTY^;+HU zr~|V8^V;Fp#7yuRX(UtoduqqC*gSHLFM^YIFziocxooW}ebWS_6EI8x#< zU=y|xc|$qClO7p%&c21Kyx7qvWF4X18+ZPEle4?mo3T5JE@sMm)@i+dckArn^=8}B zd%l?tI}6y}x?5w;aL;`jWX z<#fjvz+-gt`&k#a2FE5B7dYXmkCz*LmQVKWp_8X3lFuYj@!*Mmz3LrQ1c5HZD}6E> z|Ks!fEt0(oy5{Qs{(dmufZZbZ<9~pj%w^!Gs?0#d+%CR zlBCv>R4}L~W1F8Pz4-kq7^>Ps`m{1_*o409gN<`zIFLfM*4D|IW*qoU8*{G1Fk_4)IHn!aEA0S=~aCE~~z)Kq9A9KUI>Xb8q$mrdK!5*KmR$^-0$^htIEw`A^J$ zJP9xBJgXC(jYXHt;vOFTX;Yax!Fc>3hBGBT32k+IURtMWg%G4JIDoXEE^!0Q|~{U**L7iwmTv`_DZl zc1=l=cKB0zwD?m<%l`E_bLMhJ?%jLt+Qn9lTvK{g$8O^}t-VzCxAq-l;l$5oU5kV0 z`{S)lpE4yCEWCQwt6f!NdT17tr0VR6d3WyzS#tkm%HC#XQtM-SAK+^lA#K`n;RBrB zi7q8<`VQt<4{)0Ds*fZ+bP8=+)J>9NBOmS*>Jc>3t3>etNqW|n2Wh&yX_SLJQYyZ^ zTMHz&kQ<15Z7fNx<9h@=Y9dLuc6Y^>HkmOps(p%Ag^O05e!3L9msiickF~7Ae*Int z9FQnUb~p1XQS~*4xFshC7YS-|PLN~aXlO!Guyp_%y%=Cq;?@?T@7WjAf z9(zfWbg%mpk2J~aMy)wy4k1b+$*<-NdRa$`RZ3FB{jV-be@Y9fV5X!6!MZoU26L{c zHj9NNX~{vSOEaAY1?d8@l{0cwq9jQYhs>vSj>7)Pdq|Qbq5ah*Nh~L$FpBl6b_+?8 zdOJz}XW<_owu4i%9_1;$oAv0?<91C*1>5f}AWsiTGPB;JcD?fCV>U)DNr|PUV$*wY zQSTJO!gl@ks7jJrnn@F4B^ekai%q|i43ld!dHVy3o36?AyAhYjUi-_$c{M75WHKqQ zmw(*Rs+!PMNt3T-#)X5@l!ej7fKWSKaxT$+-kK2IDrudncppSOHjXZnye zr}Ga!OH2N)%nMK>%hedQvd&C##u5@k_r}UYI1c+Dh5fC_C8mV zE{fpO8q%~Fm0)WVSG1O-h>Ijia$S>8B(Q1uf)>?C59Suf*N`Hm#P zO+PVq+@tD(ZsDVi4qx|p1^>nThv-uC+L^dt?6*&+`;Ytb=c(u{e82Pd-pbn-;t7q9 zo&EOZx#-@C?z8B=itfAUtbD&{cV3Im7dm}3ZwK*$vj1Z7eD_lPy%*hE^`oYTiTSAK zwbT4x1uT3Y{v02}d5iM!R{wkyy}yO?eWJKP)o-QRmzSdZsQxyuSFOKUz~ZOnx7xhk zCMeqPUaG#UIAG$dr=FXi!EGmC`P2W{d1`)7srIcbi`b{6=(+jo_-q2!zg0672Z(tZ zx4q}$KSBR zL^Vf5Prbje#l!wBm;(O57N^x1)jaL?(iWni_UQx1yGbtj+ZMmmW!R@b$w`?puj~Mc z?sfwLUsvoN@o<`2>&%SQJVJVhNg#}T-KX@U4dAKKwM;TrSw2GW>Q9VvWN}BMG#SB< z7d2VX25AulRCt>M*ePM52YMW8qz1FAcom&|V_x;)dBp>-{q#eD-a6geN^PSnM@U)( zCh3~sHJ?ne(@!T9ss)%@;8Ppxbh<}6xwy*Q26U>|1WhUc;ljFP3;-ChL3$I!*0Q$` z684a%{RG_vtr413g7Z*kG*CjX8LfpLaLpLtVU9Q8>Tma2j@oETXv-B2VM5iL)gOye zF3FoEMF6FcLXsJ1PDug<1JGwdIpi>FipGpuG0Zx7tgB(d%CrJYT;Ygj6#zw>L@X*t z3bCS`&fk{k0<`!FV2iTC1iO@VO#t6W1%ORn2=oPK8h{HH6(kA#5pHxg zPq4$%!Y5SnBOR2KqJj=mBLJX9VnAJ7rj$Ew2UAMsCCK+Z0@)=bPMkQA2|-A*4n&BD zP=I7IO?37smrv57fS|K+5teW^BLHMJ9YXU?0w%76B0Pu)(IrD90Pqo4%nRtW8K7qa z+QZ10XU9z{QC)9N_k8=vUwhF#JxG>V+=)ghQS9Bm#{>OPPNeY21J88EK;S**|GxMF z=yl7;h=m>*l!Qu)4!nf{bPP=1OZg-~yNFDK2VdI&#E|cZd=QLJ%?Ck&SXSx5=RN+R z!kh8mNZS9}=k753ABT{+!`JE#-@oLRxED_43K$qu?4YrZ1!p;hf$S9Na~av~$556pfU3hyG@ zL3iWFOafY+xq_AxY;Q&w9Q&w84c`PHIZo$8vh`2Mxf@p((PbR(U4%R6exL)3M*X%Q zOY;nD0jS^e9C^mYEpd5W=$fxvYNz=U0QGyDBZeMnnNy!yU4)k!^e*yCjr$Sp*A*Xf zt_jrr7GwgOxdH$!FXW>D2wdgs9^kPvv?(%Vno~|(XQa~!-bDeO?#6w(_wI9z_bQ|n zu3~O(Kin64+5i9z3C^$a;7aYy&CQR?l$^NsNT(gVivrr+jUC$_?$-v0bFR$CXzZku z0n1duPM7m3HOYx9j&zE_yC|U8^%maK>}i4$?A0F-@l8P~OBogB?}wax)J1#&@1mi& z-@~d+f%<|rq*eOL;QnpmRUdR)!VsfC+bV`hFrOnsFs#z_l5Z_jfeORCW@7rMK&l@@uUfanMdRr998Soqf06|p&&F)v*bV2ph{`DeD*fBe{> z9J4GJd~e|}T+r{s=K3JB#cfvNJVxHk`N>Er6XM7(o|+bqa)?jxMow=JUSbQo5m||M zbJqR6X=csy2uBnrDgXcLh+?co#v_V8785x36FLcaF4lZIA7Tc3kDQdt?Kt+KCZ8yZ zk9atYv1MmEqWGFlb;g{Z%SRNIJ2JM$zcl+|qS%FfT@n}J=kfgyC9e4y&%yd|!uDaz@40hIQa;caUi@5sg}sRkIG@z= z47Rl>e!askf7jWO^%XOmBiMzTeMVMV{G{x;euvF;VsGcad(cxAo@+!_(Ww$!i5NQK zcYkLIT87n~G;Zw?Vqf-z5r0@Vv%b4C*yD!e$*QW@P|gvBpX6owj>sti3@=8q?L?Y| zIDs`5pPOA!MD%B6M!XCgFN!b2<`Yk22R`xD~HTTv9h88OqoY`Q4Mvb%_O7qEc``iRRiBHCrGo6Viy&+leplh4Q)WBZB)Sl`!s z*x};bjEK&Jsq8oli?y>4?8A$n6(7PEV?=z* z@)1Rkt%tLYx!AmCJtwBFC#`M zNAU7AAO132%)@NN=N3g5_Pg^l20r>?p0*U@WBw5|@ptfJ`>;wyMe&1+6`q6LYNjKW zYQElQWFJUkhTN2~2ePHb0?aSpGVDNq#0@Z2dDf;8kJluA(S@4nH<- z9JA)-W+(7dD$d&a^ei)1PiDi<82IRm5l4Iv-~FP8FV#gnv++pr=W0B&L{V(F%A$AF z@F|tcfB1nvmni1Wf86GOu8$~2mp$?P3&X_Mdh&aUpJTlBh|5-)8#*#1{FBx>{IZdv z=riS$9yk83#-b=5RVszgsNYeG{(xKm7rduxOnhU~sZ{6x;;KLFaWfVa`56NrU6$wp zgjlo8(m(N)(%j#)1x@__72ptHg(WW zH#Mz@AOsY5cXx?~Iz7_AWIV)hYwJWKnAhJ9$w(gn3vZ1b064TE9`wmB0sw80mMRQL zvKS0%dk2IHypBMp9_VBP6pDvO7o(ZqN7$)E=QXRUj_nPkresVw`Jk_-B~e>DK!MgH zTtjW|fE;Y&QB+Sz9yKPxDCYOGY6anwt>HU6CZwx+Q!GuimOOH62Lu3)K~~iE4wRE= za42f%2+;Z_`5LZ%U$s1XzFxK5O=vuPQjxJ(pz{nwrzWs$1y=!xN#@X7JJ2STqK>hp zws#=i0YH#inEx;m5Y;T#==b-JuhC7|y1DiE#Hn${w zRmZHvr5BM+ToL?;DNoGn!ZRI6=iZ<~eMwr?$w`u=1Ds5VwihN<@<@Et--j`FWFtvx z{T|elF*cthwPlQzlFnm{bv{cvj}?-n!&xfT_QGV!eYxIhDT+O1`l*GFTvAG6ht1u9 zePu~no0V%&QhM}haNomOap_%&Eht^o^C{`J7baJy?@uuJnM+K$~oT8zZ!wQ8YQ z3)W4#2RlttGEchgg~^nC)6>)IleT6dJFxX7K}~PN7-L^vlAf~X%RSG*GK6k>VS;s< z^y%3B3pRINQqX2>(CJaU_AIi@c@o|B!X%8;jm<3UOcC$_#ac*v^Y->D!}QF!69Gr>QyC)ZhK)O_P@4Svb1Utdq2wD=W&RbB(~3y?8sCjKu!<3$;`5h zFgkp_*%kcPg)I(TtwZ0^$n-CatXXtP0dtr2b*#HBCs_|3)uuy7Jj_mN`xh!!-8C~C zarma2eA0O&N|v^;+glE8J@we^ekvj}ONMPd!|7jUw0>_cm%0-QrKYsh7zJ{YBq?Ih z%R18DB}?}Xl<0;FZu?GCrqp1vgM)MZ$VDG{TLET0c+^_;ul+1Wsj?$Hes&Nvy`9&d z{n%-Uu1BLZp;i}`vUXUMU3CYL!C_#J>rbp&mCN8TI(d)-WdH8WAkgrSaX(OHc^ImS zYK38`m~xDMSE@~iSB+d#TC(<-$x*pl&YccP5oLAgJJHKJbi)O=-C$PMwlLYj7k59U zx42$F*ke{!FlxwSr}q5CA5@VY;ae*Sf}!<2-%5WM^FoJOo+Z%g!i7Fo{;0R|v6u`F z6Pkw1u6ydLZ7P$&VQ{kbSPoL1d-Z{if9QS?sIoi^RYkSJaQP(PLf0yAF4?BTt4=>% z+Tl{GON(#nv+ymjD)~?6!gRw0xAkO`9ei>3vzf-9)Ac1xc7z$;N)W6UCP~tLO9-^O za5LM4ZDwHxhix)veOTMJe9Yi5IJv@3Hwl7u*U8fGk8wW;0!5aGp{l4t(TtHK$>t!U z?KK7NDR-rhqFIw;!owDmq+QL67Ht+xG+Z!+Np`R%T_-YG7Uo&ll5?#?=~fp$YtU2z zW^mZ1*TxkVv*%z2htbKmNUCA9hJWb34WH#R`*j<6P(b>Rn38_%b-uFidKd;ek_O$!0k$)4M^$?dD*AG)uGsj9jq#Nye+ zGKPwc`vk#kpM?~A%&HOXFXGV+7o2@-K~Mq74rYL?2g7lT*%8)*$-3|lIE#x5`Ye+b zU%F{?)q9AxuVb^L>z3u}N?MYAxRzj8wmOy$U2r=2q8u|P2O z1|K~6BwB~wL+agrulvT7pc*bX*yHrJSvLsD4rYL?2LqTLVLh0v3(wD8D|b@P!T>Tj zOxR)WaGM*=bR+@bWWkkO$*~*_>`}`<$hoivSXDF&!xvYd(>C&+ID)s4Gqh-}s}F+T zy-v>$dOwT&4~q>U4**(8x~OU3MH1a`!NIB@v=@`?;LKBI{y@Ht54ss zAbj_Gw2)p*)`f#0W0zCcYQPK*Ln-gNrX%d_VL0|A5>Hqs-y+9%KF!^PjCz z;NM^8h0D;Gxve$)`s)B{c=wr&?m=Uw&wmkz2fnyQm!Pr2|G!SOibhwUaa&>l%C;I^ zfW`_j0;QtS1^#0SURP-l^^}=|*O^-u?Hmz^F(_Xn%4yt|L3kbFjnT~jlkmFi2Shi$ z=bHoO_YR^wZV&z)*NNMM7=+SgQ_6aq1bUY(MH`#Va%Fsh&FOu3bavPDf=5JE)Xw8k z$u{NvD%XQ!w9dCHAvg|*@c>}L2B#OCMRP1+6pP3Oa+3=hZ!)cM2*}u_cFx7E?!6SR z!*;)K2>{yBP9_8~W4e-p?X!FY-NONGlQLTYiNT!QdcBni8}O#`FGScQ5YL7R|y!}4y{&b2`qO(8b+OG3+-jk2YP2{`wXyM zKaN15kjQ<$TjmHM7Kvz0wc>J|=CL@QtwHgV(JKrsc2fh`BrleABYF$$3~j$b=TKpT zMV=76TILARKx*;kcAPj2&3XlHFbS(vMXg@otWg#_#S#Q|hBn)9hmJB;{{`j<(Lt7> zH`k+aKbWn!+6>^X)6wxb;$C*aZX*c-J42ggupMVgSXV%|!ORh2qczov`>{(1V97ED zICE>1Xzl=1EK?IFe_)Z+7qrO*;ka}!5RPjisNsNXJ3wk{#RWM;2c-1|FnxF6%)_=C zbEO3Uha*m|QRB4d1GO`>S%h;&77gEJ(TXI`kAS*)RTMlz09W2o_Y46UO@aUnTe^E2 zEdVdiUcENdfZ7?_?7?y;wEgmZYXiX8Q9xkX6VLJ2)=3j-|E=5+vd>xO@OeHl|G9$y zV*ayKbQ%f5%;7@Jf4V5h7uY<@^RFYH!krih;`DrU%~FEnC|P7>sb(uVW=2~Yj#%D6 z(0#qjxzHg?M;2LG>R=XO#3CGIq#ddg0RRz)>Y{l0bRMlx|4{Q}ppy>(D|;jc1L_ji z9*%^H{^n_Nt}NBpRLe8g>p6vDqe+SO9mp@*=T&VqNE&{0{Z6QM1$TPb42nwVZ@&nwN*K%SMQdfa7`^{$b~=~cNhLY$9i zyyBu}fYpgB-eFn;PJ=xyzv2QT!9;g=_tE5fX9f&6Mey(tz^yWHd0mi^dptDrbGB%} zGhu_t6>H|*%?CIqT%MJs9?}DtcoVvC9uTo?DDVY7X)Bmc)4})1TN9aSB-pWS+qPGw z)_>G19-gZ}hKg}5)`Q7TqhT^o6p%4j0ZR1&PmYzP&Rfsi+WY=Q$S-WU>WHfZ0Aa^r zhKmtlr78FVrwUtoA;5#9JP#)~5R%UsSf&a=j+LeE^9&HtRY+u8Bs#a^6c~?lJWUCi za+3qv~rFTq~5Iur%rAxchgpe0fEh}vPK% zsCj-)$X!FYh!bG7!U-kER-=_YY_@fD@$EznOZFx_yd=( zQX^EPVe(F)H7-2N6()6#o7#KdoVPeVAN?bk+@?Gq$;{lN1Mw7uAS*lJrnb%b@T|i} zJ#zh+NyC7IImds2iQR#_Yd9tax}A7bRnFk^JpevAp%rU7Emghm_yY2*ELH8c#fcln z0S=d)*jG$BnKSZC0M={tnr;OkFtq!8Yb=-ZTw|{0zgBo>zGlPc*<{+B#JBdsk+YFS zV$j`@rJ|07A+1IQBs241PA3Ndwobxzq_K6j1|kxvryzi)H4_0;w5OisAOt#jtL3St zi@Jh!a6>vl#g?@-RR?-x3Fhg_kiP4g8RkqKc*b^hY*BW10MoBY)sco^I?e9hyI>Gy z#x*K)ajKA$W9X)wuPv*ZF11bsPgk#txnO~^ylJuUKSUkl34uD&&|&Iau`A)ZRs#SC zS!oJMUB+5gHC@sh6EyX@7zzSeeS}rh0(R2^fod)Gj0H|UN0vkuMLq>F%WkdYHQ~i0 zWL49}w^EdRq_$Q}1-MG?WXC=8x9f967=Y#Ob1~>T(h!VyZ&Ew#=<(E35Xsd_8MmgFMNYK~^0u}8;@<0!wErkQh z>FgW;6K}n$Yno1zMks4+JOEk`J|XQSjoyrxpmDDcWQbiSHQivtzV(5MPC&HXvr+lg z>Sr8>lnzKRx67DT021vq&jvss0|%Ybi)`~@J1tPb7A5rdf&9$Wj3AnQnB=AR)e>47 z8z;j|r?Mg$+iL~`i2-Qpq~UYn=Kvh0ZnzJ(KGDT7+hMu%_?1G$tw|5|Z$iqD_Ih1X z2}HZ7N`?o*reCZ#f+|WJ1*dAhHC)Q-P9pu!#Z?Bvk0sgP2m$SRg$BL{5NdN1$nt z3HAK~kuo}1Izm0lhib6l5*>k#sr~B(!pa2!9jO8#3aD!38Wacw@#qYKK!igZsH(uk z1r~==GXtvGhk3ajpms^43zIc~cRF3@daH!E$QS9L9uSAY4c~&kAK>QCbM#d6T6qHK z;_48rydZf8~%lsU97Dm zXW>Nzffp#Z!J!0QZD6tot)whvdESC7P0+);Q%QIi;OI!@1U1RiX&9~3FadO%507=2 z5C=pk+k1Jt0U(=wm>1~D&xaMQfD8$Ss(Hi%3U$M`;ALseg{Kv!1D3XO6%eGBi`<3c zqELZWVpwD<(%NeQfROGWZ15Bl3YP(mZu8+hQ!^zH093uxEKLBi*@t;3J{V;9jFqFX z(MS&B0foBZTj)I!0H)d@cPui-MK!^*2%=pyHNqokL|Y1H26daM?p1Z5#kJSFo5Hx9 zwcA#psl!776rIIg(}|=nr_%_*)IQczmj?hc0|MRV!^o7%|4tv z7M)z|tW`Y6dLxx$noDhEOg=)oZl&o>9K-_(b;Gyd-=Aljg8EK?kDYIyu0SD8#smT> z9RTP-w52fBkiz_w95Mu`-~}=#@DuU@(md!d>l&3U@N9N*YP!LO>7UO(o1LyJkssMY z!zmqz85=zq>NvYRNjy3OJwUhlFx(P>RJ~>ZfM`2^SF+iMiTkLKipT~StoJ7-R1v%m zHA9k>WJhzWMHY>x9$>``o$HUzcW0_IFF96?>@HOP8M9o7UiJ zc5zx{fAZ)x0|YY=l&tsAW_4;eV*cxOiM6fQCFVa}BIHJ{)<}_M?$IF&nq5m}yBH?e zZ>bqN>h5VIRbaoRCZn=N68CLANsWq!`qzi>YBa^zjOouiPp>cJWV@hcet=f>^9{(5 zXu0BYXLK80?sl->Qj<~Hk~rYcDs|JQxrZ-1bf2xYLaWitzxI9ZaAX(Gw91wL1S{Lc zH$)I1B42c7#-N4n2Ky~F8I>&=%`G6;MkAhATxd0#m)GS#ftD<=vR!;a1Oe2AqWRc{ zi_GZg+2CfY*>9(cl1X!*V~oyZ4$E%Ob{kNYz}Xs<=& ze(RQSza32;dcciQWeZP+ut0&;XkK0yzD6eGE_R0nMdNkpdwKYw?>vnK4MB{ZYmvIs%Ad2rPM+2;E7e5d| zfPB&0ytUIx2E%g|(?$s*r2MXoB3=L$=IUd=`K zW$dV2Cj+**^lO7WF6SVc%rIHcd(4oh9k8f8-u?{D#mhBY93+<)qSiBS1UzlP*E;fe zv(=aJc}?5Gh^GxWQy!mEmu@DD`cHe%7DuZu0Dg%{QWf?Ddk+vVeg8LI?p$)Zs4 z0_~yt1d^yv>1Qt;mDGjY*1Yp77iuh(^upOTw^}CW=Ow{SXg!opwB_nr$3&5O$^v z$<_M{HI_^mYD7upk)dWqXl`_lJ$up*!z#PbPVnYh&FUKHc?PDii#xaW%x=WHOp2K z|8qp)mBL_+=B9egAev)9+6)mJL-fdm_4&8rLHQo`^%JAyi8ig^CxCu|P@}x>^tuNG*)rU@R{kVjd;VxM|vv{#TO&!X!As zExb~Rl}}j&vNhH~+g?k}wo(PaEkjVEG@*&nEYY_klIu-ILL2g5ae;bE(BFq9I$CO> z(NNiNplXnnNF`8p0Ey9KDUm_wB?nxX`_dVs4F1qL^ z{r%t486kFCPj6kfggfeWK~Pu=!_2;-%@F&dlX-M4S$O)jNy+7Oy4vLc1O`D4Gy8(E zD5a!UI2boeS_=ZfFpS_9;T6Qp>?;Dp`1rqN&P8MC;ZP5K$OC0fSO}!&K@_RdSt>K3ERaUb^;vojd-~C z*N({N5Rj;KZ?_tE)$6ikkf5*@mX=X~>15dHAy`Wmscm5JEe$Hd9cW7iF@UkvW6?Ga zRec3e980wJE-Z@^+}$O(yK9i3A-KD{y9amI5L|=1yK8U{?jcx!x4HMe|J6TLThlXh z`s?qU{-$bXr%(5g!q$O@o7WB$?4OJ;a3+Yx_o-T5O)2}vOq9f9FuV;Gj00h6Fw7gG zE!+B-={8XsOiTE1Sou~Kl1{V?mZPM8qIb?}TUNacp`#zjqJO5Fo_w^4ju=?RfxnL) z-E2~h;{d`UxBRj+?GnaLpS3k;T|04=y;EvZF=Q0L8;p5(&W0zXy6$a#tW{5|)?s0- zuo&|TDquSuRhLn$+`k@+YzQ594YxyRrA&$&28}sD3bj&bwBnX;mNnsZy|qC@<@`8*m8wp=@b?_9~mv@{JFX-fwu^pEoZ$shSF|0O7L`jtb8 zy1uLiL6$WsR<3Xq=KR}`8+MdKQ$aqq0|s@M+&c(KIwBDkBm_G&APnnK_{XN1MOeSAcMm~wJ{`V=HH;g=(ir-JYA0+P5sav0jggei+QYVJ5$wENZs>eRYj&A@4WEZpbW3;~B&&hvnkGEaVoI*!$=@snx`1w}-}z@K6ETh3S>|Eu6=QvE%irH*>* z>$sLb>nT-_Imo1!&tv3G0xdryEq9zHn8H5^vom(o+>?|cl79>ByhyPz0+I2vg+z>~ zc)+!lCuAjqcqdykl?lQfX$a8xoTmcII(m3cEl#@Otmj2CtNh#PC~WyFssw>Y^f{lR zemEZyb!k-jKDIdzI3BUrjK$BMWzsr|lA_ilHXhEnUt$5z%%;$IZp(F`^SpEGT={{I z;qd3YzrG3H21w<3s#gbg3r?rs#X_?3Q*&VM3B8=*@~1oKmwUEy&CmAv8xD+KWTCum z1jPR&!t5JNb(BwE+E24DxoCX?wAd&DsiA3-6weV|w8^D+aoT!3hrs+6q0U#SA&oFK zV{Tf3g5kt%sy;Cl*j&T!7o}9q^-@0ZOs5wqbY4M5iW3S@A>NUuN4ul1Iv;Vi#l)@j zK*#1zmO|WM$TqLE=zx!|KsekEebZfYjqMGKKZu4o0BTL*QDp}Y4;qz!8}iY{74x%i zI}X$UL}ei!ZaW@3?)XT@aI%KT@XUl@)SyrGPUJ`zz6P2B5RO8eS)9|~@57Lm205UF zU3BpIo&_xMCWSh)xM0#%uq%`!PvAvz;kMsKQ`vE9lzav|<4e)Vy46suZmt^};v^ztmI&d8*OUcEwn#{4=cKT{m9jV5RNM*9&RmOnpF1C8C*DeLHz{O;Ewp7ZFkx)wvd z1KbcO=gUK@l+AbkmQtYnQUH=K9HcQM1K3~l^hVTkl=U}s1Yd5WkDnuvs>nwaF%H|5 z3bw6j7bAH2yar&QVLAZ+Hc}d8;(k=3-jpH0{_J&s8{N8pNED?Fk{p=+Fct)~ zH5)F8h5GAFnN@C7xhIRzdsOz%QEgs#5bMKJdKgmbBb|Y1SEE>z(vqwNS6GN?Dbg3s zt`!WZsfm$ZG38By~z zvVDyZ1iLp2PqtnV6w#9xqTvR0AB%Sb4Mia{Xuk!|K~&f#NomwC4wAi9}Mn0 zy37;SQlVs9o0keGPxIy&dPMo@Po?5U@*)XdFJ&Qxdz~F8hXbkX*g~(xZ-c0#4B78N zcd%l%u*0P=nc0*z%q*hPRBB|pS>N@iH0$P@s6yq2g5Cgus_AMvi}_OF=bA zaTlBNH zHJu{J&~M}&op0rXt3G&bmIX|)*^m3m2KtIoWLcJloDtYy?d2tLpJH_Tjs9j3x~>e9 zb@+8H3(k*`a|O*Tlo+W8Snc4Eu=9~Mrp0}p^5aGSPTV2h@gCWY$3vt4^mesBGr3x; zH@;pV>Be05%`|5?p>}wwZdtgZV@Ewi+=@Yb+{rZx`Lolca4|vET20H(MDZxdd)!vp zpGtB3*9`tY>B2kcZ*$!Z14BC-NB&6DGnzw)edHJUB*hu*gv9Q};O>iT7cfOx%o7V^gMF&(ILw zdlpNj>XRlUAq4w4vzjH9DrX++XNAnYOXwrhT+MAX(cE#IowE0aJkVC4o82mulT|e)6D4BE+X>fx*$N~3eFyqGl;#A)&3FYlJ`sk{YNFD zg5u^0h9&@U-eavW>lwO7xdH@rWMX$APP}95dLbfnhr*Y^cBqVM7$j!obfb|sL0{_- z(_A$U-n$bS#FGXU?_6!T5m0KT`}EE3T6MtA4qM zay0edv4Dw6*FaROwEJJ4v&Q>XLeB{!1<5k?)w&;I_SYF#W9ELIdB%4vQp5=GX zxnxaYtV(D9b}-|}v9Tv&%?5dez{9{>TfwKWWsc!O@dfc_(2Qo5tW-BUQ~;31p?&!c zcxh0Q=$>{Ljz9ycAZMkw4=U$TxCvjilfp-|eMg<}zwn-_s-j!Wdj|Y*=7cUh_678Js{s9I#r9yam+;f_N82+cQ!5#etC_&hWJG10(AR;K*OQsw0FFX(jl9g1g# z(svG^*ng<8!`v8#QjHn1UqWOBctaqloNymM5s5HX6VBz2jrtUF1tL10i%0iYkaCcq z9#Ss|{X&&Pk7ks$OA=8$!(t_uO`}#yX!g1^+*VfIb>d@oX~_(vjajgHuahrLib+|_ z9x7Sgh4nu72N$t3Tij@-(*8Cz-d@f!enDBbOu_Gh@Q;bvr$R7+Ul(p6U^P(?w>#Y_ z=@NSmT=b3{IH8sC%Xq!Bz8j2=(&iMBhhk4_dY{H8dCDvaz~(o~QwfDvgC)l3P~{S9 zxRZd1Du3tM*y!Wmuj&dmC5_8OPn-8t$*cwyan#EbH3Kxi-RQ{b5AB9Mj|OBp=3u58t3cj(iFZn%neBPd?%)fJPg5k`I73_y`Z*_)ah+F=Qxp0Gy;! z6M-h{r7j?`d4gtOzAXDeA#(CqfT-Fmeb%MEg>&;AhFBhYg2v{;YFAkwNK+n~Pt2KZ zn$p^D$8uOZ;zMM>_VhHF>VKh^E{232(w`5bx6zVLWhwC~m2HsxWVkY!0_$>DsR{p1 zCf41%wt+u~ixNNEZ8s_#zl-L{E8w?=jd^h6^pe<-kBh7Zyp%`ci?(B9EA?k%G_+K=@@ck`3GE zd}68-taX{qsZBowcL=jIUG7ec76wNlzS8tH43Wra;0$k#h@c#c;fojJiJm*XFBjhf zl}>#QvRiiDqdG(4VG;CNHCWP24U9G396Zx&arx}CsH82FAVfd}8L+|7R;hSV!XaeR zS=Ki;W2~%&^2NTVC4XgCOMGg8HztV3*0D;51}OVsH2u{A>+lqgp6)5MtON0`49~{c zx~hG`5ezP2tWG(mB$e&IWN~?EpY9&jVc0K9g3RILm8Y${4TEiHY(3ZozdQAUK*?)- zb7L(wRFhR<)OUrdivEeyv$vM@q61Ejq%ZLmU)7kYO+`#i=1gWPt({scE9Qqo&e9Sh z@IsnKkPSs!hhbS(M1Tg0bLde-_fhT1^xFi6>L}Fw53M@;pcbcYDB0L!_f^=4@Mt0p zNz8t&zM%(lG0F$jm8s3mu5KIPom6=kl^I2O+m{H8?536_;TK2rJ_8jVk{L7b3!CcC z$THBkx<;cdkmHUYjM(yc;25c)SE;y|O+->tkS?Lfl4{7e)G&MN;Ap!^rQ8i2?_kqA{(37Y^^A{ZoG(yw{^fCmutBN&c&IC$Hl+oZFGVtYZq6ioMdbOEUl zS+c*mWBuYTvL)gxhvVw5R`^DY!}cIQP>^q)ncXxu-KszCAi7zK%kjVuW_Mm_1BG{5AYZ-RrptTM>1(Hga06gpdu+pxd* zZJ>iK$X%n94agf55QErx&bAVU=w!}A7M5nDm}w#B=m%3dM4|CP7?Dfpcyg=IUY)4z z9a@T!EcyEHNSy+r%QIyz8ni3c*ic(@j_|+6D>#X(7nk}nWXu~EWge5OR7B2WFSqR% zU7umXE_7!5WzgMARBl-Y##E&oXOUj4*NC_<-61N2;eUUG&7uWqK?!l83Piwk%fN0e zft875gCpR$>PJ|0AIxM)L}_^f(9#H?B1%RvDZ{K5 zdC|u;eJ+uCmd7@IG~KqH>!7cmK^C%S7!P>1NXcqRe$Ggu=mZo^lZ16aDMJ;gQA=jhjS?9Q$3ha;Ff$Srysj|&OsTb4w zKpPnJC+)Rxj^^V2<>cr0($&nS&PW;z@-7vZgB7U>D{6!&sKr9yU!A{(VW0h@pEKO9?aaSmbLVrTd^V~eQmd)@2KbG$s5Cr*1ntp9)Vjo&aQ()1Z z=P8PYs%q-)0D!nh6RnV^CfpD>tWXo-)UKhZ-LE)d3U5(Phv|q(;7zkwnh^x-tqoid zw)2LYxPpRr_ZT8&N5UkLtw3V@PG<|R7K`MaQP^gk{ILpqp+gKinpB-(48zSu3f->@ z$sS$bScsDJR#W9h0r}-n14sEWfhwWsOrB8RoYbmEKgM3uqZ=cVT45WUu#j#ruY!a;Jv|jnv7(KErKcJBI_ef{6`l$pmEBhZC5x~^GC)UE#VkD> zkSG)(GzuD58<>g}ID)>P43Yp|!<#Ts8@NUr4=q$8m@4Rv_tqa-L5r9;ESL2oz$Ay& z?xO@od&DvAIM|}H@DEZ$8N*Dqr2H|`* z=NONKiIr~aa3q3x8%j>9M-?`6iy|MLGP=<; zl?i{{de#z`(IH4SCAGw8zAAkb@n_PyUk2+QdiD1q;)I%~k}SvcP6;_qcaD3!Q$T+1 zGA8&s-Eks^G0$C5+Gecp#??Z00`BkJAH2tSo!8BS--H!9)JBhJewHrWjXOw!b8(oX znk^?KR5p+iM4ym{$@cMrAHhyuDNBm4RHM}IdRp2D)7l)XWnSD(B(4#%uf77rrfKT? z|FH{m^5V75K>3mNdRa9!Sz6&JjXR0)3tnlAftAr6l&S7q=N{ba1I^pn(f9-CJ@3o6 z5u?aWRd?@<#j_*(nBFji#(yN0@VobeG0T=)3u!@<8#fL^Zh)J6(S|~TE+vejhYA~l ztxwY_yZ8t@_}2Ev=#gf7GI{Ht`?|s?H54M^{#o%FFS8SQF{N^pU?S_ z`J><}wIAS=Ctcu{Ggu;P6)uJ6Q2V~e>Rz<(-8*K|lw#y2xaPZ7@#^qiKtqn+P&;Pl&_*p2Q}^ex{-In#uhGg#dPXX$3-V|!q4gWg^)&f$=A$gH zQy0neIGCq_d6;D(Vdj)E{U~_$a4g|ywum-&oNIkR)O`r2{Ks+a=XrZlI2vBvmIBJO zpctleo?QB8^@vN2>CX+9T!pk|BZ1*0#Tdv$a;k2`quEHuGMCQHs2?(|H0l0qT{APK z@h`#sX?L?7^L6Ts4ivFq6qhHbK}ThUUI4t5P?AqVYtgjN?hCn7_+o=iPsDJ3ZX8(B ze5VzZqQnv7jw>)*wFJ70R23u^5YdF;DdvWHfPOElrT7T4z#qR)j&>&Mbz#J_`X&rp zmjHJZF{e9`vLNLCJb&1@u9;l+p`C@w5L&U<>(v`oCbcV=0|1s|cy{n{H; zNAq)a(RR^(D0#^urGwPsVE9F1nOWzHgx;23MwS!P_!X{59wcm`Oina@>O{bGD0FW3 zbqFK#xkP3R(R;vVkaLB|g`KOh>Dw1hI=bBG7P!x>8lVT)TaOY-mzckJf$sYQ4f6Pn z8$iDmP9)tjX$3{m0-MWl_Lt8wT#nGeAq9EuFyus~W4HA=a^Li`VD3tPfz&%Gzv&YM zms^jbvJ0Lpo41ni6$U(E7gTwiWi5Q!H%Ufm41i#SWr2y>K(^6M*}qk-lNQttJXaK{ z=GjCgN~Md%|6-Uz{l)mmE_l3Lk=njPtyaaxCq+>z`t?XhlA5dm@z(D<4d_pQmy95V zX>*{G96MOM>?BAg>{yKrX@SDZU$NEaBTUu%qBM*l`K_0`=f%462*?;kOBKZKQKaI7ZQhPi~HxJSY4eyw=#vXb|!#?1+a7p3?SQb47XZn)%OYR4}1L;f@iFm~bp5wR1@4g_GE>J)5NPEgNtaTL4;I0E6$GN965yY7Lt&t*LauE!Ae z<*cz^wsl)D0B~p5+#}|s=P2zBDKdNi0PS^G#c~-vY)1orRFlkI?uJHXwZLK3+K}{o zKjVNk*zgm0iff9niryn{aRG~3#C5+kFYeSJ$ulH`HP67e85Gnz-XSXB0{Oy?ETjQc zpHMj*kqxT1oui7nzvJxmIPV|5glsROS9`L_Lfmk4sFHYkK8T87DYcP5d(rW2>f(eo z##vUxgU*l0i;QQ*5$D7lY9}?3Mm1|0DR^RzfA`Gj5yXf)(%XoBEJ%q(Yr8HvXP#$1 z0g0Wf3>+ZtXHOf4cK3qzec*4KTTyqaoITkUUh(GM5b9fN-oF)Z!6kH?Vl;j<`3;U0 zXkd}8%)PnJ0L~>SmQtMk*eKAL0AMNj)rYKpBz4KN#jUTMHW$tSFqXKMJ-~L1pby7jRg{qO}iS`>+cmm?JcdZIC(|( zjJkt|N7^4gUGqLI%`ILEUpTvjG8Tu=cReW;H|+@5ZX1C2`{tzJ(+f z)g@pfEQOZJG3KE&WdQ5w(?U%affQkcFj=P}x6ad&8rPrNTMqpNJ&uM2`zkKe4JKMv z{^XHa@`R${heA|TM_mK%grfXX`-fjuWwz-qJGwqdjCa}cFOi=i)e)P zd=ptm+Pf-C3*B4KYbKYMB>{L({EzJwF;;LQ5y!a5>I7JVK^Y(rGG%#l6Fj~Q_uOg` zR)S9vZO|`ppqr{ofUde9RBKQ7tN`pBBqG0rZ7n3`AS^{C4M7_+13R@44z%nA$LJ=( zD6qhG6;BcQ!cfm6?tEJftPib0py8(k%9bCN*U%j2D}=h{osZ1_y=?o=-=-%#)_!_ReT}A zCpfB{aIS|@WI3gHOunUI!ci2GwPmq=;k_#EHlnafQ zxFJb2%3WC+X}gC9@iQtpmo$q>lqKwl^?w}l)s*qcnIF#cdn@F zSaw)glx=B!BrRl@R8SB8idEbsyrU`&pYy&gT~;IooOquFs%@11&&l)CNwrxv(S=9& zfxX-J$!e1ru9RD2z>K=zO9}X~1vYJ_D6ISx9f8C5w6)OSvb2!9v1; zNCYth!Xv_qLcD)t+4XH)Vuq1agynajPVYE`d!^9Cj}bX1P`(#=(BxQ2gRq3&zKw3I|l)PM-a)eq|WP2VCH+VamP zstbHTR3BX1Y(&3k+8kEFm^`U&kih(#i|>?93haQ(rhm2e#ZRut9s{mj(^+fa(cH+t zO`Y#?IeltONFnZ(hYA9t-)XN&6u_Ogw?dqWtYwtcnhpB;h*DcIp+t)Fz&+t?@S)s_ zY&pXfBHiGqz6p@gz>XGk(it3PE+xXPPzw2x0~LdM<8L*u|COh7F&uk(%tOlI=G`gsQmcU7%q{e>o3q^IraT3wtn z;Vje+QhTn2p-j196d3z{ol_w@GHhF(^4on;G>gqY=K!C3P%5&AK~rBlVn-6}xH^-7 zSygi-t41aO)GCU1KaUE7fzK?>Ns}T*@;I*OBEB!%+Q64BA&!qHV4ws>i<^DX;7cBH**>W}C4= z1~xQShi%1Fz>HljGF`;0lRH>EUL5lKB0J={vZU|WVi*`a-B&Nc>uAPQjm{f0(k`~( zU4z)4l*`c5mcN-qtJ9f$IAmaf5yW3sUq74W^<-dLY0fi$k||4ILl02jgeOc=T?Cwk zJ@UvGfWpxlO00leGHTb;Y|o&n^mq{oZ|zL3vOq>a#N>m#S>#@FEOei)vc`i&yxlga zQ5#`h+hj>7ke%twOl3GdL5%rz9tYsbB7NJj8t1B-Y9Z`eUV=Br2#NzASIZh^%t!X2J}q1RwO_{_)@da{`@jW-bc2GG~<@y$RTq`F(-p^6i@XVKAZP5 zBW_av`ht|(=~_;K$C6&q>XF0C-WHuT>s!K)`!*AC7hPk+Cf_k*v?%UJAa(Wpgn~S0@(5XRJ2j#$TmESn zGOhq7Wrb|T{GQrvg&*jPtn%KT=L4IY0e;=F}?m&tb0lL>3aE6H(XR>sMngv^{V zM3>PxGSzy4mbOL@)m-XnU;Lju?KkH8Pqu8 z&n4+3P&Eynn5ZuWi@Gsf`kP~8SReMA>JdVg7JORZ(Ad7Le7${Avnlk}h1osgW2cPn z=}N@U#R%29Zh7Sw^EM5RFE?CYaPY|LMYB#l=Y*^m^Sm*eE5-I+_Z7A2makT*+?-}S;f7L`pR1Q2fFtR?gqDIDl?6X%I1^xI-Xb*d zWT&{{Br5C3tR?{gc#VLT_sEpEWZz#648u>2-J9WpQOF);twSQSnsk44AkM+7F%UZ& z$SjiDSs_hCaE^1yHUNF>LV-;DG0(5wm4V z^|V|@eQ6Xk*#}#$eFNr^9tk2-?{6tHh5k#IW8nMD6q*R0Vb9#VLMw7e797HnVx-N7 z9}b9fGwXc;y`tW?>uXshzjJ_bbV z6rNwKKzbolflsOCgWG7>y7YRrs^MHKL!N1{_u7{0uTc)!E&Jx}MDdG_Bfsz+w+n=% z3-i`s=kBPirW$*z=-3mgHLvBcd%ck7h~<~}6MQ2FMndEMUO3#oH^jB! z$%MDw3EPVm6{ECo+WgRc=*v$s)a(D$9qhcdy(x6_F8h9y+1rlSWTeu36mQwF7aF={ZRKr35Bj8<#D()&$mV z1we1;Sa3FS3%&#UwqehFKpM~CJ2`N<6~-Ewfgd=>2{MP(5ohLOPT#?Ifa9tV&1Nymzw(X^(zZC_V z$tO;Kjfwd(?@y9H+lmY3>Bz+Rc)OYRB+g-5*tGYvWZy;eEY$$N->11Fee>n_`ALoJ z)imx!YeMYHd(9r1C7wO7*>)+`Tm+_V>cm$-=A)plmd2mHTdD0>%V}BokFkp@obqjZ zVR=j|7G{-HvkE7m#g zzu~yI&xaH)U$av&-9)wJ*e&&iay^%T;$e0m(Y-M5cC=3?00 zmUr!UUVwkv8z0i}sHV~T)+^>Cc{0k;p>~TvW&=+Uj4A-1DjKtGiQojECANE8LuidDNzRgPb@Uuxd_QthO`T3AkGa%~R zgLpeoFx&Q6jfrhcT!?6YZQC{r3f)#ihl)At3b>e8!wMgT>d8FRdmPymMQ$@khwB}M zJ{aoc@ta4U?vhC_!%J2q;Q6twA?Q(K3I;^uu#Lf;$Q8(3i4{bU_;VJXem5U-6-x+) zH;m zo(UBax<<5BW-jOpgOJ1H3JHA++R?Y_+@>1W-iii6r``%sR%SBP+T6Hq@wuh$1-&h~32b#=)bMmF_|F!mAYi z@amgy;L%r4-y@eHbEK@T^xcygIu;U1$Iv4eeS+WjN>`dkEk^ z8#5jda0CrmMWfe-{h{(t9LSy+Ji|02G<0RT|>{~|^} Onu7kX+yF8EyZsLw0~eS8 literal 28752 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 F{Xb>hsDS_g diff --git a/website/static/screenshots/step-settings-engrave-general-multi_pass.webp b/website/static/screenshots/step-settings-engrave-general-multi_pass.webp index f759102ae9a781796e94b26affa29722c3b449c2..c4889cb2a0f6b8e4355c730a7c1257673ef20cb6 100644 GIT binary patch literal 27876 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�Z1fT=NdS3(S{uJY7;(kK}0OW(Kz6o zsH~)W2Gmgn;#ylH`o!2)WYP61c6!QW>O>IBIGm_UU2^uy=Z>j%N5KZHPh?ox!TDha z9cB?p!M_N9H0wiUyVQXBl}h5a(v+yvnuk)kB9_MKL=mxwNl^eIwqvg>?>DzSO*CP9 zD5umB%^J50MVhNV#q_0iY{N4;dE>BFX_k-y$1bhrF^>5R^{F6QC+aZ`zQWL~trVmI zW5O|OC}c1UqKKK6Rp!9V2cmZr;t{WM%&tP$izg!@_=L7wj+n#HtdEwgLNz>2yjk2l zjW1f77pvwvW`>i?DP~x|V=f~!nVS*)cGf^8iKlGVn9ZZ> ze=@c7SlWc&SDUH@pj%a>EQc&Y0{H9PU>P+4g$MAOOgn5Dewk%@4j2sKeHDIx@e!ln zQIi?@2}YHxO`jO{O+Fv;v@*H$=k8h@fq^QS2MlRPNjhMN&I96Z1EO3U#6ksb3*ACE z$mAN4jXtScV5Er@w#_$JEb5{Mx}09Q+C0z)`HovAoh8g>P~)ynlc!Muyp#LkUQ8;? z8>+AF*Ykgq!t7OACOauIo?@$C<%Zy$5Z{KxqbMw=FgNDLs3-&S+gcEeqCP*zVo;Xz zm=*w5a>2~#08A6IE*AG-lzjE3W}tddJ2~V)Ium&c#$jaAe<%$KHb&M#)~RCPk~%q= zzZ%URZJ{&RNXkP^qkRDx6;i?VCGp`>D_7NlSYCBe>AXa|ZFb>FQ?gj+&BeoBzF>u# zQbl>O#}l0F0`p-niwdMX?=zhG1Jrhs}9eG2FFzu$Q+;TohuiS;gN%bBYTPeZbqFvQ#dUT8b0q3Fv8Qjo^M$=eEP)H z+JnBw^k@mZS3OUW{@vW1!aTDF9K_MLZLOc5nSWap1dY^vFnjp+9&V$@^ZGuANyH4! zoH)JC9xFlFA9%Pstf3&_giFX8bcR3raeU*ZS)!h{UT_4ZTCLehWVcFF;mzIL^*u!qJLuSIkrD_0m2UW&`;ODIM(py@awx} zyj6bM@ZAPL5!#^#Z|)TV6p94Pon2w+J9H;j6?&b+EfUsdHEo_}o22iASR z=NvX)(;T{oZ}@zb`eX}$A`5N4e+gI&G&Zp3@dyqb((et|34 z1yt3+RP;~S2K0qkq^#?3Af)v!ULN^VCFXB_fZwU&{g_Fpav0}g0Z1__T z8@(!}vFDu}!3Nqijy_&q2NCZS?v=n@nv+*_pxwKVg3X=bPm#tK0p@wS4lkH1=@0M_ zH#;j#7N}=sm9dkN<3-?4LVJJXM*+*e-ar#J!ew-Uq-+T(gOHqg&e6K8beIiJ%%bkv z%hBEFtxa7bQ8u_9)?g`JbhVcTb+HaAnm!Xj_T2+sn8Gn|mZqjI<6?f%G*trGOy+~n z%R(uJ*~%$^0tg#|fUF9(d6kOM$-3l{>xy+GdP0ZwW;*B=&A^Cx;UXD*tvF`Yx9d*v={_Gy7+ilG7J!l*%3CkuT ze5o4s;iK)Z=n|xTkWE;{Ac?Glk8lLg*aIrbq(#x7_P?Gkob-p>4Uq6uC&cEQTDno0 zB$*-RRvi*!A*Zk(R-I)evv>by_f(_fwTz!ZQ%jTjz{2f^fi;nu*6U>z0BhvrKG%xD zl)vJ;iR1nO#-XB6$eYZqN7Eu8B~>^ue9NVcPqxO`v8C!L$)cT$I(7zAT6jT>1fUs` zZjAm7Zy;898-g#Q|Kk!3=2I<-I~%no8*}>-`kH(wu9KE-L-EB-cK(2&MSA6pJS`8t z&_d3S)4Z;1Tw?jL=qD0xe;=R{CFZ9wo=oijLwhOXzhYYkElXjHIAmaF9W z1u??a6SuwLds?-l2{KgijQ~`bvZGC!5YbrIMQ;+Y79>eB^KQh@SJU4yV9PdVn5IyP1gC z`1L=twlv!k6S})?tXn))zuoBBtOV9;33L&OnZy z%xYa;TM7CPHDltu7q>kuZ$@=*i_JPPc`7}SfsZ37L;$|Jhj1{!yVgz>u&1nb z9y;%=p$tR2zqp=&?RAQvz*3n-<2X<5y%gBPJ%?Tbp_0zQFStPfV7LG8nLJz$ok0Sj z5H`Gsz9YSOE=;S3biY^&RfdN}*9^pLKQ%dlU13BvAj6oOk~upY&sJHXB%$tNl1+Hk+T4h`*H6cZkxj5>SoARR2iLwFGbD z+U(_~6(=s%j{XRbv^1>CM1B1ACVHU1y<0 zqib$HT|5VpNr2<#Is$&ekT=bBHA#5A+besR!n=Z-sN+qZ**LGPK>iEOWHTu7P@nz# z+smvYuY-bQVr`PgxyW5N#Ly|rZOL{NUfq64W`8-M*XB?p?U08RSNfC{KbCeUEK;Ik z>ED{^S{Y$!b~j(rA;EhwP-$}vZ5>a>yiCg-uX-WOnB-<|L-C@$(<|JGM#3T6sSnZ} z=m$T4&j9PF_leDXE@xb}qP2$;LsD_RR0V@S5>G&80xai#C~rzF>BP0(oX~X)^gMPW zgi7|4!CSB^vKMaW%XN2iw(i>5wf)E4mv74h?Q(>?h4@ix_XST$k&5EE{^hX^)k;CU z!X1KaI9mRx;@fJdw1UOr+)>W@SKfCgo3?i%;5UQzoSIoq$7W(#8iV|M4ApTtl8%15 zd&1%Qe8Yjr$+Cy7sg{1wbk~ABjB@TV)%0y5Xp7CNPMX=uI~xvPP0{_t$U>YmLO>?H zJkNFW!k-74f`GU7MRJpIq>~x4iTl?L$uu*#jd;0*7Sb^}0p8K%+3rT#09v8X#R3u2 z4*Z@<+ztf0W;b>(?5H8?Je!s|zx6deWqB7NX{s&NCpnwVrb3>HVii(olI;|BR*w8k zdD;nw*}N|nhH9&-)!m#4@D8t;Az!nXlux;G-wKAJT|gCOEtC^2>|`3_~6&dy(Q`uW4! zD+*;?@T87C1xs0XADczr0-qKxBc2LB0tEHBnm2c8c>6B8=KHqq ztUqTi!{c9sv}-?XqvFWEo7|66>n0Mz(Oi>ZI>&42PBu!?=hIsFHgu>I|H=*-Gx45Wf>0VMv)rnU4`Ccg`Wg5N*~r; znb$a4?wP>0xCKgMpG-)aTcjn@+X_zMdoUfirR&Hi?v7iFFEr}`^8;fQ|`3fX6k=!;t99$nk~D$?*&!HB%2GF& z%=wD9xWj$*)IIFn=<~DPK_s&gqkcI|--2-9a_3E6W46Xqt53Z}c$k+5d5ENdFaAN4fDg$Ypar3^t_LoW!h9R^Sp)e4_;tI>W=*uH$1VySQww^sAWU#KlWaFznWMobl>1Q(t?7qt~@OL*z2!8p_%Y~~uQKY8`<7ykoB{z!A zl!@KMK383^*SDQ-@;L~r{5e11W(G*AV3q=aDHQ?-&(Dq@%26%LNbZK!&ZxL7l6`Et8P2@J&%Bc`eX%x$tbMD{oW12zB^UiD@)n^eWj%~xnh9^ zX6tuHcxThzE^o)Y2|s8T=D!XEM3`V}obOSfH#WzemkElQ7@s)4v{H4XoOi7;RVtn0 zLn9s`We~`f*gucC*>r*-SZf#*upZPQ^DFJXafZk75+Y&I)OZAykmvF5$@%k)wtT82 z4&*K;h&smYTiI`!#WtUZ@{a|0)?O!ojZ5$1DejLF4&7BoPCY^hNX^^QLaw*>&Tqc{ zeSCuVB=P1&Zl4I`MN01>4?u?T9Sg1n+Y7q!2Y<0w{-A3 zru0;npe#lO)@348Dim-K8Qa%k?k(Wmk@{N?D8>nMEPw4igju}5qD|4GAfZKs%BD%J zIS%-dZRC3rjIcVnktY^tjBtRuqs_Wg2BK4;W|P?>4c>Ix)^*LslbQr=H6&ZzUd5s{ z0bJ8GPkYUb{~RuXb@!du#++pG#y+W^dh1natif3&|M7kC#X{RR-{NpQg}`INS8Qq< zDzoE{B+HP_k<(0;*GPRX>)jF^ci0xwz1GOoBpfw>W&|yc=Sqw13DZNjwi%aY9oB>N zeo;k%j~>Gzmh}WP9p-8}4GT*JO_NL5bh)4ymq=BX2QqbGRfO9XgWTjB>wRW5z+(Q7 zpbWpXWFUKquWB-=T`rT!@ElebkO{i|E|evPMoPO#qu>);h6G6UyR8bj8EtDRnMrkb zDb)P)KoRtHOngbUqV##0@#GVfD46uAX3Sw}int*kWoZ~s1O6g|3D3fQeJ=*O3hp2p zPyN}07zFM`oxSFAm^uz0R}Z|&{T$`kV3UN}So~Thxbd~>?CmNPnm+2akCBtwm$#)w z2fi@fTBR#HI`H3Pv<(M77wwo!1q35@R=(}$0pln+2w2Pn)D~nqy}&XPQ7Z-!s?wdn z28cX@n6(KxZf| z;nkBg=IM#2MsNP(Hbyr+ItmnuUZeu=$5y{W`rSNjDPwCbJa=HCDg7TkCEE#esd9Mp z$V^%@h2IM+u-;|<-FmH6qq@wcH}9E7agdn&pu#k~ubb;>T@*c7g$nh>kqn&(b5zKj z<5K`N@_Nk~iAhqN-gvV71c;Gz6sX7~y5z+j0eMQq-$qcnB9^Yuplp(L5EfR{!*kp- z-N{AIGxbCC_Wkm>?$xzKd~u>LQj;XQnSRm!B3sVN72_&8;z=ZnKPB3A_l zu2>57@y%tz9tRj<3>T(*IG4)@8>+~SjZoUo_4F3z{U90b9u;}I{hX*nJaiowXfiXw z{_^l}J|)N=(lCokv9P3(^-Am;uW+Q^)95Mj0f4<5pzT8cn=&MIyoeAO~CgV3+3$4s8v;y0anL#?DBcDx2xEX z8`nqDM%~cxcTiBHrKauR$q52$?>?W=YqH|wkoKmUTt-L==`BC&k?Zjv3|@! zZk`O%xr74J&}}4iuxqzHmCWH>#p&ge89N8G!2O&Mz?M{Bub0ulk3xQO4^T2>2!hP^ z(Wc^t8=SNzc5JlO+gA;_ce(w8JoKyG_$IBI77!?gJ@_$O4!1uP!)MP2aKhrJ2Edt3 z{^Jy>H;oRa!BGm>mBMc0#mLq$UDWsq;oQXt=|#D)htZa+7{r7pDdWgRNbOP3c7!H( z+)~nZ;$nvSc;h64d#^qY#+>Y&)y{>ix+w&zULRq2w}*rlzHlXsCu|I>OnS5y_WX~L zXwY0$sMcz+5$LMCHH$f}Jtj4k^>;l#26_+!+B(c&gG{(>)(4#ADku?3*;r;s!{`00 z9OD7!lthI236$W(DwNfwg5i&Xnna<15xIF*SyA^m4*o%D@AEOx&hiWZ0WLxIuE@9$ z1AnB8$t>AoDhJsP8PwDyl^nv%6R@Iad1?^5Bxslk3;RFwS%w*m$sH$UY!G7}@)*DGzS=9m*dTxXIJJCHI)jEDbh`CkS+H!t|;i_ce(y2_&3|~W<(f){K7Tw&6P3uM*6G5~%DeIfuhH{YIhA#D5UCzJy*3)(1 zjlV__#7Z`v<6uz=3h*{wvMuRRB#hhWRn%b;Va5;TkkB%Yk+&?;Tqu7=DH$JDos2m( z3s`S%H@{a?z?El^o>A&Xbg#q-dKOdf@#O@}wzaSdjpkX>ss0>w&u|svFSmdQYjB3M zyL>1io-+YOmk9z?LHun5eB&OnfYF_O)Sn`)9?-1#srZA3M;$`QdP}Yk1^|H3rtiH} zeR2dt1|FD@{VokY+qxT>?4W@l7|?QJ!kT65(vK~o6ANkq4I9o#Ie@y^a)RblB&dgSTX;_s%PI##zF{N^&r=`Z zQ9fWIXFCn_g5x65SpoXyOBOO$J{_EHrA{amGoWgdQ@o;gpxKai9VJC4a%}su>1o6h z!0!V{u4NZMU}+vqnN$QS%8v+sAg2vM{Sg~P-1MlVk_jLSiEx>)Y53!2D9?z(#jYsP zk8b6mRO=Bhe+dXwbN%2$V^S{|mSN0uk&Y@rb2`Ywh#wI{hiBDy1ift;@gvsnk0Q~M zVe(BWAbu4dA2e}g%u;S0h@m@&wCdFEQ)=Qu;;H5>w)E4gZx#YHaDb~gKmn9!1j{yI z&=Mj+-WUb=U9+{MFwl~x3Nt(xiliouOym9koDyXvW9NGa#v;c6{pML6fCvQXqfmzPDxrc5G)OGb!O+dQID&oS07g^4z`-iL&aE|IkfGmx{kSf;ja-JY zg*C%LkDjGq6k|w9D%E|kryfoI^mxWgFD>LXIM}-v+nocGDwD}1UjB>9kuIdy^|SB* zy?s&7+VNN6MC{V82SbT_DaW_rNSzviSnF*?Dg;o=5-Ml_guMFM{V5Qk$eVO3x(j$U zQ}YsMJ$|-lG}bjh0^oHtYAA5Zzjb$>{1Ea`+B)6)1rI><4r}Qr+Jk+@MGR$9&XuT`l1z^P7Nq8|8S#Z3(Ji82S*G0lrz zWx_{xu>TA1h83wP5QVvO82?wX^dQgM6ufOYuL^KX4ugrD<#2lux&6M*mI?5(iH+A+ zKz98srTh5W31; zScqB*Jhe-{>Xk5ms>8J?djlM!w@HMncg+C?2V2pv;B=hMAn^X=ACjiby^x{jCh_Wu z+g4f3fO8FJU`+}$M`!RIw;DIwB4N-S}c-lbo>NoJ5AF?k>Sgac(STK5w zT8*YeFeds$_^hMQpf8y>#mtti@htBBX2TvM;Z6|4otM@uUL; zs)!RYS}o$ZIl=`Rqf{UVBtm=UG?4TW*(fq_9f57TR-HL1mPsyfP46*4!eW%ux&JT& z6VHNfiN8JWWd>(mdOJZZ%dLs;2Pj|K129T7z`N0>A)8C*Bk`d4FNTd^Rn71yZjjh0 zhWFLx8Xoj4CH$d5FY40 zc8g2eHG;(x;Ad?esAI(&zMvs6y}Ht7yVpc#d7gcAaLJr5wr=rGVd|@Uc$>p?S+2hp zS}0yORSMmKKCWsDe;FoQ73y10C$<*0Vz)NhuCz=-_72RiKY zsETsTOMim-oIy{ILMiU_H1zD-3cb%|vQpK+bi@84^wN|07R9(_(P{<|%zm#er}*{a zQ{Du?)H$6dmhOOQiqH{SxRiKV=k36@2vw1VIL{`aMiF#DpUQtSXx7l$FTYzi(w?|CmT$2#Z|=Kb{LDQZcLR zsomx@NVSbP%ds-W)#CcjI)Vb&VF8x;cnraSxNQ)5uyo=Hw4}){2sK3gHa@&k$%f5V z{UwEL3V`GOzTJ(>{j|%y0&T!Nf7+XG9_puxeJ~$6X6bU&E5YFSQdtELw)GDE#Lod5 zvcC7VkolUxwK1Z5ss7)~KE~=9WVHNQ*(yH3Gg=tu1NW^`0GOmlP)}N5mm>>Enn-w8 zs#6nCRCbN7+u(Qsd+fC9yfj{Il{1q0KSlK1|A{ zemipNv$7MQ{$|ucSrK_Vu%Z3CrE=m8h?=G(B1xMtt1RIJKipkM&-zchMoSIrhz*L- zzNZ?Gp2rq@YVdIkn~MS+@s$Xh3P(-+=KqbzeNTsCUT2hPbg6;ov6wqyQeU zv!4@kM$ml5KV&7868{|uz#vCibh6vL&Sya{c!V~NL9u#DT^f!YWD?%f$&@{^wx|FH z=3B{}e3v61&G^*c?8b5#a7oVC)KSpBa0=gczklMd%+*Sa@?gsbg|;aUw1|dYHkh`j8?eYTUr?fE5b)xk1c6m*@rm2< zW?S;xV&yu=Sz6GncDdLPoLzC9CBaF*N)P-^i`ZvI;{&ja*8&7BQIXkl^B5j2 zEAu{Fx(OahK5;1=Sc6Wo5qhE)Qst%C7jXbL0t=d7Z35IFdf(py6f*Vq-sf!O@())F zybjfLj3W1m-NL+VemOYm{+ce5*0j9?q(vChA%&mxrXOJMDcAA05`9}B(ICh zYNVEKqV!c~fcgb{Z?}9D4tmBC`7Moy-^td+`Ib@KB)z9)N53=KA3Db6Tk!t8Gn%qU zgw_yAUytg|d;KJ#E7Zr*c7P3Mt6fqObN{(g>Qq%|&qz$A5)7B1$5ci4yehgGjGVUn zb~_~WlCPT#B^1a%eboS@bCNukjVLihsah0f@DBW-*ggdS>2iK=y8iHinB^BLBER(B z-h7{l5e;L7i{d8KTn_aOgjP9D$ez+<#<_cmt)|u%O3;y#C+U@0siNakM|i_A6=Y3n zX`PkFl>-igu7O1Poa%#!m(m+O3)apdFi1T85|iub?J6k81=N(hRV<%`uk|Cn{~3mv z8)#W)TXOjn4*u25B59#Za(#-r9P*@8adIEC6X`IoV=%upFiUnhwrG+H)1*TnLd${Dn`% z3I@frx|wAf6Q`wH}@N%O*bIYx=4^?1;v3sUg;SE@{)X{W68*O(AS?F@y&=c?$a11$%a$ z!{8<#g1@Jf5TQ)r>T=bk$^_$D@^9<@fwTXK4s|VGQN0q#06Q8l@YM$3*pBF*c~PR2 z1i6;PdVQsUbIHK#R++pJA&STKrCxio@1DCzTz$i1dqd`{irVH53$e6XOG4HLqDf%n z@lR_jM+cG)WPmwlK*5Rh2rZeCClQ8g44NGKFIr^b?Fz;Y3u!9%-Ub`m6`PR|Gbrk z!3K&Vt81o^;*}#>ewr-E;)ZRClb%VX)L|AMrbj&VIU=3PG{+D*-<%gvZ@8Iqi;Ov{ zxBgO&ukMfVjj2x&NB(KPj%vqSz(O;t%mCwV#M93JJ?RZg0>D0#dBNRd1!_S@Cz+mV z9<*3>vJti+MS2eq4_*E=BKtOX^HBuY$q+C>cSL2K59+)9?9^gzy>SN$i8_KWF<^}< zZ(edRi`7uGWL&mI@vm?JNrjvsCmj;2{3Q>e*{2FOz;HqV98Ln@=z544(F_DQU?&Dr zR1uez7O(V|s8sy>24aDM%d6-|Fy~yc=#D4#T%tTk@*ywSI-~MI2IKlJc&i8Q)~*Q8 ztu;$R3ymyA-8)`_u8?VreG)N}e8vU3+8b8%jsk7TEKy$6?+(&8XH9Ph@7_o$W!XxK zYH~Adn<`WgE7@JWrBd$nN(mtDz$vNWjFV`Y6whLw zLITyL=fSFjY$f_?U)Whq@X`J4uNMQ&* zGGd@=Jo4|35G>*Rjd*?Nf8{+;X@Y;E>Ck3Dm#KKCe1!nM(;icF=e3d2$`t>$b8>rL zLHf^?fc+uh@Qam-IsPgXu|Y-fPuJ3S>#{ujtrGoL1JX}u131_89^&q#g9n#n_X1vz zSByv2bPdZSyexEx$``d!R&@F;r*TPuM(AY0mnG2*&V^2#du0s(w6Mr=d19;{qY=Jk zgnFkuG*!l4CQ9P0thkSz?2x&ZSe!6*AdT@ly}CVg8pL!Nke6Af4M4hZg9y$8^yj<#{T?Zmig`8lwPdZqtDi)Kw z7;yy}fH;i;B)E4>yjTp%U6w5n{&?U3y(-mA4y0v+X`SS8!YdDeuR@sekFzB2jlOL9 ze{H>WR2;z)FFcF82G`*379g;=yF+jbPH<<@5Zpp=cM0xJAV7i#cXto2Z*%YczW2|Y zbM{QlOm)?-x@!9D)KpI&MMH8|JNP?7t2GP@<5Q33 z({bsWrFoZ=`$)JDR|KX5*(swVmTBisMxd=>zRtVt8HR1g4uQf;{Vu3%enD+`abA7j zPGQC`Kog(IuM@ew*7duYfB#sh23PMsnR(G4gkZJhgv&VrJJvZA>a4zdsw^GVHO^C< z8+^C-J$LpB-WCGZxj3;jSqJ%lg}~4F05nB4GATkL&@W6VI+E;c!o>Bqp_cZ{PT}i6 z9WL$JDva%%Z`vyeh1<+4W3f-e-&yPm2?^a!_kUJVAKddquGI&cVWBqmB@|Y%6(hpn z28sY8YpPEi8ht@!NO>`=-o^&y>wgOpA6>BQ<16^uWXb#iG zrD@o`WO8TZxdM%8AFRreeY1eH&n`r|)4heS4Au1V0Hf4hJEdg!ZM( zkFB%+IG)jIY=kdQN@)ro13@ktjXcUmQr;Bwbf2@@DCYOs$ypHt9(6svLH&+cfR%R8 zXGCz6vE@v^T`@H%*HgtMev|7rVn0bFy6a!wx#E~Nkh&P>F)%HuoZz?!7)GWFq}s$0 zkjl8xqnYj3gm9cBDn*+^@s%4D9(5UExGJpfz7~G8y;kbBI_*eIHHWIBfkz@{%+}UQ z(ply*mmprnp;+Cx*=d6-emZvtt*A4>`TI@3Kc!R6u|AoX7F>&@AY*S3a&D)u!=i~$ zNHi%n!S! z*IsU#4000?E=OaeAn?0v+JJ<^=t*}HVy-Lk@_xdap)wuoy4~g@7HOg16ub`OVQZKM zr1MkJ%muZat4uYUG&B<^PWK+3^1l)JFY3+OGwn>JC1G%h5#`V)bw)~%m|4+-(umST z@B^E@Y`HtT;hJ*9Ma9}Q8u+Q!Q&sJ2j+wVOo{+54zL*YFn_?)kH2APU`??dY9$QhE z*&!wx-AX|aA0({s>5J24MKfy6Wm!q+q{3X!$A(i)%bC!cdJxzpo(mb`BZ0kVMFG8g z5}|jGeRSOk26iyL6eOGMYvq4q{1n~rhLf6VNlun<-^oNoN|#X!X=G%`O zo!oSVc8O$z5EqMImk$Ahm|i6VzjNd>GIIdBE2$~1tdngoDvDD9U zsLuyX7>W@lzF{kV7T%$6E32~=e+N(bPIegOyZZdHChAES`hPL;+HBQp=0?o7E_g*S zL;ePMsfWU9Ox719Hr5tR%%w0H=p>S0fAZDh$e|)NCNi;-ZV|#!f`2V0@!q3)Q&eh7 z8Y{kPvfy-tSetX2hZ80zC;6gFyl$o3>+bUXxev@`VD`tPGMz2BSWY;nWM2Tw%O!fs zr}*e2rLnpm?_2v>@sM@O9jW+s`3(kq;@03E+T%beqlqYQzk!#dWGCiPhj`7aA~rg5 z1t@^bw2Jj&vC(K}PZ|net>lOBzShs+Pn%e_zSJ9b^+MmYpl*|_!+XZ1UpE^nl#IUS zt1lp5A^Ko)N7Etg&NW0z*p$jz=5UMFfe^yhSM8rTPqNBW{Tm70gST~@mFLtaJlqL% z?_pxAY%>El8nUzUK~&^M9z*>*pHfzvyxK@iFJC$z`EkKTOcrB(7RHxf|V6|{g#nOO&NBE~$zOWbvgJBuyF z-VwyC;6ljEgO-o$lGW3P$TaJf^pqn^?SXG;&KZe6!3gmVw&N3D5{2&ZAqZ9~`a&?J zD78<>`fRy!;&9|LJ8LJckqPNb_*}&GsVDrOE7!y=YP~(dd6Kj5>0rPRR*C9o%lQ4uaM!)KrLR#tQ@SQR-+p$#$M>Mu#%$fCw@x5Uq5%!< zPEK=&rx7#rD$nG4P(b*O!M>Q$@T0#{Ddir*pY2huZGa?nVymPGcKMJt1fYb>*%Oq8 z??WHN5-(byUkKU!kh|%P**9L*A*gPO0RmgO@9eg)ts)S)Mr_D->aB{uethA?Sb^q> zGQvkTI3eG5PS{N?9~BseD;)!oae{UZwx&jR*iZC`I%wQn%?PxqsvwNH2B0%97PRi-Bd?u}9~wB>8s*hmtsmlawu-LGB9mfJ zG@A;{4kuP(oI>AIEqP+@C z_^WJ+8^ZAbJ*@K`J1l_1Z8XXhXQaf9Y~pK`soh@8gNJwOW;N550(7vIO9?); zYtSDnH_0VJ|0Aabc+fA=5Iq{R>zS7v(%Kz&b6bkbz2Hv7?<9xE&p)q|QV^@^IDrv{ zw8>2CQbxiO+e&h_B+$(O09-mmnWFIitQCz?(?V5ZJCde?&ZY1JlX4jo!V_=N($HKb zUkiSzhAEDmw$`>R9g|u^>9+huan=%^r9WpkkdV0fT7JM!i|0McdrB~cEh^Vfc z@zSlPI(-p33aeKVWiEyPph-fYg@W%w-J!`nalRyAX4zN42kSJ_?EMob$$|13j4(qN zBJXa-r7O&aB$>vFT)l|a!MR3?8a3n*6*2=`XG>k74}FiO&~pg?@0zbZ*~WW2chese zjy_)o`@gi#havp~3KPR+wnJn-FlgLJ1ckaWb3lvAE0J{mc|eUibSxo8a&MAyY^j#O zZ0A>C4KbBJj))JLKae+ud#At)tH<@sKSa{Pzy4q4=Z9A)7Drt2pILoU)Cr=Ao^t$W zAg>~h9hEwJRc|=Q;6-y-e#goTeDUJl0hfK-eBB@&pM1%Sxz!-(@C%K+oTZR9>88?Y zQ>0jxmU=N+@Yzmw39(b5%DEM;sHko;LO*!_%B!ijE^K4reS=bj&8Ozq!BnlOqL-5K zIk}US_P5Kj|1{6Qt0Q7Z_oKn9fr%f{o~lMHm?JpRJWD-eGge zmiMiZvgRL_WKt~eg%MHaYcRUMc<;FOZ+i)85@*CC7Jb`s((G-A6vchBoYS@0UX>YT z`)z)mJaiR%x4350`z?9LR0=1N_hrF5r{YIAtL44Sug4F(k%h@aLyh<=3kC{$Hy#{j zx}0Q}B{Pn_c9kr@>kt=x_6%1n8h^Mg5HRVsG!Ail*4Gy?Ry~frMg}rIMGY_obysJx zAc~^vfLqX-vpAPMVZk#1vdP&YX%SYm(Hde(tASGs73IQo=Z&=5Br3z?H}7!sD2H7nj=lYXqJCKI`2eU_XkOrB9Y&o{NIl%Vlmbz3VKzuI*C4`ckxR8*q*bb;M4@s_f zg(g~XF!*Xn>?MDWkN$N`E53IKxnF?akB$iN(8p4fW9snUl_HSSPiZRvnLPbPpPC7nyU8rsCdQ<3~NebvAf~_U5>i+~s$m5s@rV<$P zC^cR^YPpv6Nu^dq50KIDPDq3#OR(71Ls4+&;8z~o0x8#Lo9>73si{-JAOOZqTC>|1 z{~5(CL6!tAjrjKVjNaXanz^dh?YktGC-r9pM#QR+Z08hmlEP3!*H(Hcim8ZIM}OX! zqH6MxsIu3h+%6h1Ysu^GF2+Nyyk0gC)D9&LOKv8Iaqf3$pQ+)2A}qnFT@=|}$EiFq zloi0kuQuRdo-*sIa19|9(~wRBm4u@YtA9OZ`~&vWaqw8gA$X&$gxS?CRs~Nk|m0+~3l#yoN;2x{V2-!|{-nC>Rg5-+RADsql=b|%FC|-+Z<=pic9)7%w?XBw2%&^c zsY%&v+LaXvST95bR{Q{f=7G!T1q3)n=*^9wQzJ4B^YP{R-le1?S5fr4(2WdtEdy7K$gD5n*VegRI{VeUJtRW7 zu{@HM=mIY7Chs;!5$WLpj<{5SG$poWdVN5#MkkK}nAZ^??it^yMh1>3qF-Rghxya| zBFB<&jY{)FGIHh@gs76bc>w_Fe{#n?uwO<6PH=1j3g2#4YpU4^%XHkY(|~GhSqYtI z(6AiJ?)r5;AMyx+3Ul0?(ID`4Z(Zi95S=M)`q;ta!&n!nu)))tP9c@GqFhLZRHIOQ zea!V53^LH%iyU}^Yr@YRKoW&j8*c^tzV72AdDQ-b%(rDkn5npV^hc3Gs2c7_zD(0j zy_OLHNTDv%b6M)0Rcqp<`*A@%sUCDRC(3Y!u7vJn42D=(5?fKKrSE;3je}&4 zd%-wUdQ^t}N)VG+)Yh{0IPPL%Dcdu|CT4Viu^xscBFe&Z`BEsT%QgO|j)c2HoL+p- zXFMt;@cYm6Awd=(JLIQt;Vl%>9D}$?a=^7ghE1J3;5v#5#4v++6k&Dk-;FOwfY8!f z&m&58T0XLA{@@A5sU*&kfNHKY!~NG<;DC`qJXQd`JHT#5|Gv6T`TQgBy*A}$?T$ed z(uE?0Ek*qu6X2Y3NW`~HEZoS@UUhR};pLK@{g|m!I^*V#*fFpcQByBT{K}4=a3zm7 z;M#9EFY*3x7cG+eS>yf)xI%-fiD8jX_wQMQ&& z@HJeco0t8tTlD+JH1jcD8SU%hd&hkkvNtVwCK<>n0-*B$I z8`a_&Yo3_QWp#}KVh3h9{c|TVSFF# zFGM*l8dMk_DDn6X%K-|b{WEVu1Jy8pld7;7f4ac3vT?aPGl;{QuHi$l*}gMN4f70I z{Pggn%KHpm8#W=#k4(;zPet#atG<*SExua=JCv>ItIzGZ#KQq!OJNv}B!lKJwetT!k--25pB2{&>_-CLAHiQ@)I0)of6~MKt&Z zu80%lPWZ(|ABG`AcVVi@3(E%W0PAceBt1w8W=cbSH!t?PY5Uih^M|Ukx5EBH;{Kqd zcyAt?pfvK0uW*{)lx6JPUd=Mh(4H?7_c;;>gK^4+T5Cafa1nsQDRl5w38v(6w=WlmB>Ji(EE44Q=&gGLmLZwLE?`C02{HD#ZB}Zu55hJlKptmHwXZ z_rC&%PdGTR|CGg#YWDUGzwN-j&GF{jP;@;$bBam+k^UT5czj`w1&!v|9(Vd?vAP|4 z;T-x7B>7?)ppehu9E>Amb?z9aO?NLXtWo%tj3#RuZs-|Tc7pQV3(5URS*YMY4~hpn zcGU?pw31DMhv&R!&?ctk{#i87(K7eq@1S+$(woaCtNRwv`{}?_&S&crs)G~nO{qr4 z8v(cY9}|EG>&S-ukixQVobZ1}1O|%zCrtP;a&SUe->6K)&|x(HDG^;-L;z={)wm%4 z+E$)Ab^ZS%#96|L1+V27izO^XFaQ6gih?)dEyTn3U%&yIX;fAFls1Rep>4F-$2rgu zqkH3-)JroKuaGWLo96x}exwdvJUvE`}qO!7>W+%P2Wi3GKCk z0*rv6*+$z}5g5_gwNDO!cK9ur-4n=}S)nxi-i&sfgq*2)d4@;}nhw`%arA1Q6@{M~ znZ!r=Q8V>u_j@?NFQr{f5ZPq!jRfbT1q^+W*Y1Xs4uc4DHt?~$wPyWJ9(G+Q`@)!0 zn;+??4g%J2u8>uZ;yMj3LK8s;>DE>$0We!!DIah!A-?psW+tu#3Nh|RoP>eu?y?DL z)CWa-)?MX7{@1S*L42+aUx&l~1Udii8yyWror^K!m#y|&zN_VoL3s5rfZD?%O7lbu zMw&a(!!ArKD8tz@o$YCQoq{K1j&zIne`@Wt|SIj_3LzQ>Ah-)-I?N} z_pARfF{Tdmne{bmvVye^*XLrs=cJ%X%7gkg|Kd-a&%7IuC+*fSy{^TazpHp z)!E5C-GgtKNn$i(CLC>N-!HFdsE7UhZ1lOq?y0TGeJP{Rnidzrh|HTXGv`*peueWF z_Oafw8bh_-n-THOZ;ev36pkaISvBui?2KMk-Kg-1&C6-ZW{T3*4N-P2X*z1~;C1NX zYm8KRcymVj^?E8N=Fk4u4SJ-=7P}{m`I-&7VPv`t3IL*tyRdCe^93(8l?IT!2xHmB z`6QTHGVq0-4MAd0=%PQAsGQZyFUJt57-G>6(TiD-AGRmmasy%H5yXW(Rd^NJ>b95V z;o^tl!bvUi1_0|{_|lMd_-08lNIU52fEtAp9~imV8N&EfJI+*V2B2rc8=!o#0DQ!y ztay*VMD3!l7K{UMvr&UM!s@)0E7M5-B-txAeNXd~WS7(}oVkGiO#?t><=%p;$|>*t0B(=tMq!QL<+!kEN2V}jsy7ON#^f$1sSgadj9p1&2iP+@A!N3<0qJA5+Uw}{m<0T(+kW2*-C&u2p42aGliDK;=eP*`2PoIA zjU%swWC6C<`O_lRZ;uPn!3I5dsMMrlg)`Fobn#rmi%!)EMfHE& zsC2)k;rpG=dr*)v?|qM{gKVeQ$X~Usgz}A(VuWGsbG>N+e|Bj9shihbpYmzf+C26u z9&lgU`*`e6xFU)xS+MS%)a*w!;0Z@982M}D6p51~0*>Ey4HwY?eWheie^;n`Cszx( zi@S~L3U8c}pAKmiKE6s1R~yxZLJwaKX)9hD0t>Xs_>LUEnP~Ry`~j*us!Sfu#drO% zoAFOhL3~{d^{$Q-yLFkf=Jh^B8fuspO9XV@?f2F1T=-^CTHjUZzM4QE-xIZb7rSdY z1PlMrX@ z7yj*nSRZ@fVq0W!Nc)vSw>QfE>1tvQ28+LD+W6QD%~=Yk~+X%x=@?rd9j>UF1U$eACMS13Sc}{`Bb0uSi8>2=Uj(3U)nr zC^}SJ=p-t+n8y*#Ja5my^u;*KYd-QTf#)fkEi#fJNfTN>GCL*5)aqA4+2H+rp}ui> zzXb1;9So~2^x}YPqq8m-hNm7BBa1EEzdJgDr6jtW?6V2e=cXWzKW^BX12V1`3su#RB&NMX7NzA<+s&%p_wz-xlhoJ z@?L-70f7L!8|%(`IcVQAXUqcO z@=5OxGKrYV?y{K4x^cQPh?ln=!9E^r@*&)`*;<*7x5Fv50TF+Y#W5VoU@7QgSs%p} zZUk>3Le&kNWnXIW*CaQ@KFJ|rqBS8!Zc=Nc?&$^D3mhAhCCyIaDt|is4MWIRIgfRu zUr|QGk%vs$j3vC04BuN?>7MU#Nn0^;vQ}zFPtJ%1RasowKp`gV&{6#@I}^o1JE)2w zQIp_;?sxqA-{e)eBX8F=WOop?d)Fln5mM^-h^sCFZ!c5XgY)l(-t2nM;*&4*aBK9P z)a1vykK#8aVG&kH3l*&NHH2pQ4VQ~T`%~3!E4RS}T-tomNCEZm%Htq-wJiM5Y}Eo2 zsn*;;?IB3X7RFajrVxGo#jF-DApaUoDpcwtERd$K@b`Fx5x9im!!R4Thoy#nd zU%!bwzaDHfCe);Q?ee&qk#4y$3{II&{c$o_=R@*sNhvFbZ#6cY&BUcwRl<$asZQzi z?;X`&7CTfX>)Pcg`;OiM(S0~~ScT_-eBZvX`)w8jILnI!5VY7;Hru)^Q11n%;dF?~ zo=Cv2EmC)`;RwXd`U54ml7*Se8)U25q<;T`V$PVE9;B*nKKZ$#weQfOvhzCY@1LKK zEO0=U^~hMUD?A4-L&*N-s>Rk`QT(yqEFQJzspWueg}v1NU5m#BmieP%Rx+O<}$?l&Be z5`e(%&S!SB7`fnEa+klMpsm$bx8rZ**}ErPE`LG$s5igqzc#ifF=E3~>?G}OE1Qy@ z+<3V0@UJQsHE(3Uj=-fe?tt#X{P8FIY7;hfw^r6&BK+E;|M1HT;7S|?eF?%m9nZqa zs9^uo#XpB)Rtm&JFiv%f*Sb2WNwvZsu+$oopy0z@R%le0)7#`lk+|9XII8C;+qAWq z>qFz8*l^kL;mkZr(sb?4q1{l6wL4(Kx`uVNB$OnPmd>)u*`JGb6bj*sWRug=xrHf% z+6>Inrhlj-s5hP^-IlG=r$r>Kh#0C(l4^-R0K92YZW~tO7j=sr%>FL;t)7X)ukz1F zxT!(f=D)5*igDW6L&*~>^*7oQPM<}B*Lj-Wp{h#M^%t_tNLDLWx@t5A*Y@28K);X5Q_el=W@LUse*9T|kc+HEwj6X6 z>raKK`L#BsRPgC~Ffg24^h^}^`Eq+(TRZ#g@8`1`pp!uQm?u42X`RJXbRZ(q(KZOJ zXPNcR-$EDb6#4vf*mT>7hpX4lW|jCjW`g!SnkK?xCHQbcmABSyCOU*$jcA$#&E1FaVt!p6BfcAifSB zuwZVq)Hh2M*&RCxC7f9y^Pe0->dlb}w;u27WpY>(jb2rWFDy5Ru6znjA6UxmDlL}- zdT#M^DLPa`A##2Q%QaH6V|2CnEL`EtSju_b(bOugP4Pp%3-q%oAxVFKMiOzq!BA{E zR*w0n?I`(OvmuduzEtu2xTDsMus0DAxn_YCLhW(sr|C zf})r0#1Q%olxwM)cPL157|{Az;vluVKOK~mB5~2nwO3fznRf?|pOOAZXkS!3A1Pe( zQ__}OXQ9B;S5u0o(PfhJd3~Ot|EZEE8jLUr0!T?BIR-r-^k0bM(~t2su@l;cUiydP zvz;AZFD6R-@)r4dY<+baqn-t4GkG&`N4ehsvy3q=5ro8|VJ}D@s$4*jGYqQfRJ3y% zwJy)_M%nURS}OO1=Xh_^a3kmWywI5%sFW$oe8!n7WJoE9eGQ0J-lNeUEXX8%@Dq89 zar6})LbDng=D30IAyLvhN*r1NO9e#{Fhcc31NPwRS>Zj^O)|S?8&8OTsj-R8B5>8t zDkyWO7dFF|QSe(05yk`MIRSh|Wx2a=%nF*^d5%lBx|c?TVH_5-UK;kBJ4rOFCL;ok z;<~EkOiSHd*wVvI9X#q(c1Mfspni3D7QN{KSKxo0VLGS+0aMy8@DXkPx}n(>Q}L>y#pH-X&hj^2&;B}yW{DvfIW1FDTqaOe zmw!5Hl>20a)Cm^8ovyu8a*6tdZ~2$Iqnjk!fC>JFui5i_Bufe7 zODZPhN$L)pH3LdTbj1TI1$3#Yf6UdvMkQP`IMB5Mv~6+t;BQmrjx;H=+w064&Er_e zXEo)`h?^D?%3`a29y(9K{r^ktz6?bRBP>WTD%G6f5IHpUf&kDkYjf7jYsW4HP$9EEw{xjak&>O zaSH$Ny81Ccj#^F4hFk`^ZbB0{(yF^3^C8VpUFm~EId)6uIsE!CM?qE7pyo`hQsafS z$mO(ctJP?S2M*CdNnG*i&&gOBbGNMy1eN2}7*PGP1$Nmi3%i zucjJ&EPt=Pb!9;q&p%YzdB(ELwS3ZtvdrLI(h~xsf+Ea(Ufz=dbB5x_a9-B)4Q9<) zA&3G#pjCA}4io;RC=z^{gz9o36IfM!-D%W+&>ita@^ZHK%0%$-ld0o6e!8WxkyCt- z*R*0rYi-8wFwws|Z(8=G-*|1HX0o{?oPCX|rgeu4oa-2p%DhVyT%K?CZyonDC9qG; zSSzsVH`bYAfh;TQZ|cgx$C3o@;x9+Q#QF6)5x#TTe%Ayf*L3vpK(WWc{AJL@em+?SbAeb-nPu<8}88H>yQ35PGD`?Mv0!3gwyX_Q&=#19Bvil%OU zfMa7Lan0D;!UgP$eF*lY-)bUr19_9{7~7l8R-i>$x>0pN8H^HlcCfy#D~xldj#^W!ZdD? zwGOa2X>Fak-NDbYgcG@m7ldhb8tY?iXK#L4w)<T^K z%ppr5v}{lhZ4S97VQ-WVf7CmmM$%E1-rL%H`+Lhg9}&eAmg>7zJkqxWnIV=ug`t8Y zIlQ-SoZisIUA9q&se74;X+n%9C{4rmh0Fty^ucYV(u*2`$WK$mU)*>@`aAj6%hr2x zwtu71UQy{FxA&_?GH1bg)aHd6Juqz$5X)~O?)cfL5p>A!iL-fqt&u89?n#ki_xw6W zd+4HbP=xOC+#R|Se;XCM9>r~;CR~_c_e$%h7tR_N(|LN7U$os!;AYrCf zi#Dlej@A&9cfg#PgEyi(_IdYv-^I3VH{SAxZ6Wg&5rT$09-X@*3geM zkd&^RbQ^R-&pW*()4;A>IjhzSt?88FW8T;QJ0RZ9=0XXP24lYg(P{My{5L5z?`@i#?92}tB a|4Mv)1pt`b|4NL2s)GF=-2i3(xBVYI+-pPt literal 28292 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 diff --git a/website/static/screenshots/step-settings-engrave-general-variable.webp b/website/static/screenshots/step-settings-engrave-general-variable.webp index 2b14f4536de618d69b66498f6b51ea296030bf2e..39e2b6ff45c18b80818cf9b385aa4eb491e9768e 100644 GIT binary patch literal 29676 zcmXVW19T5wzaWsY;51yws&J}Y$qGrU*6yUn{#H)nQBx$)!kFw zPmPMSgv7oJ0H7f*qNJ|Gr3nuJ0C2wUaBx5)1mH^|2k}+P7(Vb11sJ^wutb0QJf|5N ziBQ^aNo3*wHa|d&f*+$QI15MB*RR2t(yV3@mU0%CDY9${UOI)4!GmZX=@uy)OzghMa}m$8+*$h5efN)s@Vk!y)6^m@ehgjjWg#5eX9L*J3RzsE3LU}EkPw99~(rn+Li{vGacSBZZg6lZ35O*O-q2aMtP5rQ;A3>b( zUaBVR__nv*IF7=DB!2jv1>C~+Q6=^G4oz3$oS@iPP7M>i#nEHqzEfJ&8w%Cz8Jy{n zpuai%oadx=Y=SQ!?JPOb%YlH|Fpe)c9GcBiE=711N*j3MGVrcL$UwhESk zv%@o%syYkOXT;evWwy|K&<-4{Gd6bmOBCyWTdKvp=|QA-35AxC*$Zx^U>?ajYb`eH zHkZxU1>wf!0W+m)(qa-(a#cTcA%bw=0R4x?Yb`koEuEiS4q!=S1+mq5Cs}$ZnFl6Y z{J1XF8Bpl1MRpn-oRbkh%)hQT_u+~Ef6>JnmrP)0f37WZBUl`Ev04k!QxAc&fdUbf zAS$ucz(lLmKw|z7g-Z8K#rU0Y9{q&NdR!yxx8gcMJW5~+xT5Z0|0r4zC2g_um-oP# zoDrs0;he}j-`k1t68lj(cV~X5_uM9OVUnLCjFm9zE7TMZMB|mF@ryEVDXlyJ1 zJHb1---JhPA1=-l?-+RTkhea@()vzQi^I<=Pjj3lk61razvN9jw2x&Xh+vb_3{zZ| zKgxV$5!&`JrSYALR%k~GJ zx_vl85$d!c2Hu$iZ;K6Nwy5;gAd`vKbRS(6uE=~8G*hkuK^BI?zTae=T$O-13D4a= z6yc`kgXOFO3gYh*D&pN!c9{@fLYYsh?8dwa(kdXyTmA@2%!3^&HVx$>5s*49UCF7z z42!^XCTQQ(DQJ7zRi~29!2Ep^l%xDRq&dY0%Lzm4J0eQz9OpPMGcJ%836*`QZ5W$U z!<|+QwowH5QP&JdDo8#Qe$gwa{2d7;bzkkPs!^f)irMh@>+7Mm?#R?lc=rA(rLsx* zZ|wv23TymOqym@^6cTXAl+eHQ)T>g6Qo7KSBbyfzo}O4Qx*u0}n|ARWLQ5^xEyqa@ zzh^$JkSKIKY&Q--vwHz0LqxD4+|v+{#U|vh65t@$r-H zY3^KIk>e|@UsVxlkeQ2a^sxOjAyJl`ifuz^9`Kp+sy)=o^e(t6voQURAo!%kqjXwD zG+-Mdvnw$n*AbOYS`^n+`yRpByUSWY%a0(6I4%Z}g9ne?@l0AHzZpR@f=Dhfhd%2O z&&w}M1AIEc#T|!FJ9RDV^q%2#!aG5Z;ZAbNzqII9srb@BLF-o1$YQ^bSdOd0B$z8& zUUb2ma%$=jo>$%O?-h9W<5)cTy(LY2I`LmRfBu{et~ZkDzdN}YU&4QP6N@G8`Sv1< z-08$Pd&92;ra4z)1ElU;XG;yW@mx>O3Bq;j-?Ybao8el^kdOWU%AS+(33(DV$UKew zuwlEU9^=`Gtnqx5&G|I+_VWLGH;^h!De?Jll9l7(&PHc&kkQR!=jS6uWw}K{So+?F z!CQ>0R8;!`)y`#M-LG+wLnmtEmuQCXwO-eNQXbtgYhuOs?P8 zC)L&b?f~d*_3cueo@X5in)x`bmXT}D%8*q#@*SrORXKO^-f+iYnxFdb++P-R6(^?Q2GmQeww`USv+#Q* zku=V1%qD1&EX?{p4;aKbctv_<<67kU+1e^-v~n=$z+KDnT~b&jGvltSYaVqR zdp;+l*Kq{OoJ_6rQ~&K#a{1kZ+yQhQqn_)s&bX^U9D0nC%Yzw{A#D1{CAy`avL~lK zMeg-n1*@4DjSBsaIpx{4S)*=f3TCc#_-MuU`X}M^NUr*)xdVe|jk&ttySgFQc~kJ{ zv=dFRjA@(6Ph?j}p?bS^a32bx^jF}=_Yo&$pOo26%#7mK$nX#GS6b{ZUc^%)@Re%| zZ>|HDP86<*hgr(7s(H%k+614}*o$~wo?f)4dte5qX4DiN8E9s58PYDSbEcq#YY8m< zTraoNgV|_uidutiaw9{}mqRH3wSkd#MKAAN^uchrf2m+A+~4-37mVe~n=ZH5H}y1rebjrHyKuBh9karkP~ zHu=MavX)LiLGQRf;>v-qsFrrYW6E6%2QYHHcM-$u*Bf&K&rPBk^meHgM_o@0nbP%Z zoc)2PW&iAZ9(51R6xfcqZ{f~wN{KNHpvO4t$! z?g|&)X%cn#8CkkWI#Nr!@r$Ef(6zt+WE!eLOzkt;C^p{%+Kl5VxZ8{|Aaed zPg(|co%o$!BR%rX+F&>DCrQICfn5JqrPR#XW%#dM40Id{^<^!iX3NR`rHn~YD!=RZ z;S84i5jxqp_T7!)3dPn^^=&xy14_XR;Q{^b-R^l)&VYQ-tDei`!61vHU!sO^7&Z2O zwtpOb1!JdAOur!$Q#Om*UUsjJ@Xr@>$X@D`gCGYfT*Q@Eq5*D$l`FIpU-+r#P7J)= zg(w^fXdP~8bViOoz1rUOn{17G;BG;#qNPx?SHRgDWSGS+{2T#byALpqp&g0MA@O_`>B{E3; zeC?Q9b++N}QOxxNev|W&=v(hm`w?apsA#Hw*TYL#BM}Gu(?#DrV~4C>Bd+$@#5I5h{_+Y3 zO<6alU55$s*j-R=w#~>0rMOR$7!K;)gicbGOI5IOyO**}7=~PCQ)^>!n*X`w7d2FtD|*Ixw{2U2m1xTPu{oq z#GZbOw!`k?Ea@Fv=+QzDKiLZ1p6|tS6Y#@+H|!B+Xg+YL{eG!DQAz13xeI@5&nOP@ zQ4{KCBoKWmdgF3RdxZ=^joxkk-La>tYI<__9DY;cQ#r8Lp4m9eyDa)Ds|Spo)8DFC za8&+@7riHsf3kX&{A0A&_Y|3;P|BaWRhnbJ$5)W$Dy{fVaq-k-@C?H{JL_}Doq%*W zk{JAnCHflqTh57wREFk`V=n)2A(Qd>a4(n3fTZEor~n?fMuAT=dv)C+G~aVa%$f~p z7;9}Nk;=5LQ_N-_i${eUi-ctDv;p&E-xe_rqU7x(39LSJH}duy9!w^VLVYD1EaO)A zwjSc^mFHkH{TYIIrxMa^4lk;LqiqWAhaA1h}WqluClQa&$v+J8+WgJIQ-S772yw?-MP zAFhWP*e5%Pz(X-e)+=PB6bDw@CKAC|W3CeFgAvWoO+FOwIw;xG#)(2rqA#b?Si6G7 z`k#x>J};q9pHw%YTt*(nZ{$Zqy9{s1XIt1;*roq4i+=6+yh}~i4OmfL_b;`lFa>2_ z${?=$S}xnUl*idD+xCX7n(#G&WJT?g*^M84dp!$6 zEJFyuCssj)XgWi(KSFInxng?#L*QI9BI)W@O%b&!F_FWu`=j>koIZM5D zD~PiVxUl6ydv<*)-rP#jrf*<6X#LnW`n^{xb3g5V@~Gw^DYb`tOKnTc(apfGx^_yj zQ+|P_%9?)?^Qj~8bH2jo`}_2i!%gV@W2t(@=%b)=@9(41_mV?0VV94Dv^5iZUPb08 z9oRjAKR$0ncVxCzj$FdoC$>WzSOXr;RAVe~^|8}PjlLb38f&ULb*M)+Y+I51d_mFb z)$Ik}jtp8?^Mlg1eT4P(0u;OH^VUK=;f_%TT1gR`RKsFDDaQmt zj@s>KHI-KTH-An!o4C)S>_5Ey$ZjY{U8y-ohVXvz(?8_=kSEPeLvOhi>jqA(*#$Rj zc~RD05`6l5lZ=t@t`mQJMZukN!t|8e+B0hAmTi&6>8s6QiP|7UXwf?r1IsP*FrKsx z!}(BjC;>XT4}oD`718nDwimzW_eX=}CByt27SA5c+2j3af(2)7c89(=DumP!wQDK`@gTlW2LHf4Rz5xaefQht7;VY4e?4cMvwpO*{d`@$dqj;KP*Vl2)kSOytehK^ z&z;o{6EF4n2`Ymd4-7`yO^snQum}0=N2zmJ!PGR*N-Q<3t*0=ykr-U1CVsOuxTcgB z^v(M2ZuJUpoh10ieE$$dxwDo#pu=95x2C}dF*pLBR!^IRya2E+l7z-zbFdvB65O#1 zXV76^-Hwi{_gKqfwlM8YPuMKxvV5g%xdMkYy?{t*;n+YmS8a=z(Mmi5hn8I54~B-1FjiZw9L1KEn0{ z7<13Ld1uU#g;D z3mxr+ckTrsCs(IOZi!*G70Ld*-_h>&S+rXYIzyxNe7aE$pO1r8!}j$yKLm@_A(>99 z)(rg&{c*HcVzD10FJH5bJQ+P3r$lx>$bBSlMjN*tZV+F5`du~6zqq2~>p+iGOMW4mN=@fnDuzL%S0B*ZJ?w5r|+VgT=cNMDC6w~jwZal5yRw<^xiAb*3dsm&+kw+Bh+`gJqJ&BLma3G z+Kk*8JFg@z2c0IS`P*2(#)1=%m0LfUxFsXE){gB7{(O_9Gy=aZ|2w;)*_0A{n>|Sa z!<+-ZCU0*r#dC?}7JtLh#s_joTQP4OK+-~rJJw!GC?Mh!Sf4WfBS9aZVNBQ)3NpBL zh7%?qFcrF+-C6MB5cTN-5oIKYx!V+iN|L-%s~frRkKDy-HN?!+QOAT$es5mu$AHY& zuboAc+t+S~7aPb>5GXCi!h+o=-hM5leWaq2Ezb%+Z;4!iO!J_*LULoo;-3pU$SY6HG_|l< zjh*_#PcE@$HaJKIJ6>&3($3F#K+h{N+tY!`4BaOxgpk z!;xTcJcNTZn1(>Z?XBN>&RkfWkCD4FqX*)l9#(F1YTcWwjS|DI^ z#PdpUpL-6KAW#$sD+r{bP*?}w2z1M=0Ig0+#!~G$`Yd}1$anrKJO0~bvs9zuAg9tc zd5C!91_WBP+ALOR9DG=EV+RP$(1&ZypB)tAAaL(> zQAmoLRHuEg79vJ}UuY>N*A{&3_6Kg_=|eUSTSGuq4`^0(d$ut%He#0m0@)R7);eGe zKL3#fS!1AovZyd+>)wRDlfzmoJWV%gopD3Ql4w{v|D@8I)9q#p1=Xan6ga^;UpWTC z$?s08Z2=cRz#IphV$e?LBG7pjQi7fUy|UrD0;mbG@P}Pr1?V8*$jwPTVZulU_D4ER ze+Pu8pv0-GcXMNp_CZXY67@>Bve8?24A;(g7kO5(gSKB>`?C&jb6N6Eg%GdwSMFl} zC`XUHiHov_I506X`}bfK>CZe>ctHgb&W4a zpNOI7!Ue+|y+=2TUn1s8c>{XXqy5DxbkvLI4IXIG&Hp?i!ShU51v;JF-8IKpdcG>x zLQwsD-=|ZekpUuL+w6YLJgi_4D2uoPG&&q`ylgpf-qULDA9#}RRPfI$n*7oyr z0L1fVjx=>ni?jGJloVt)y$9OugFVy{OgwM1OU1QzBmO~b|Cs(?C_z3pK5xEt?G3Tb z+j-1OKVVdtg<5}qNI&W1=j(qk!EW#!ruPaeq-5|)Dx{?ENbsJg9cAoLKf0^$B=ign z{oH-^iPv~T$Kn^hPc98Fdp`cx-1usM3HGS*Nr&tyK>m}g+&?m^C%*Rfor}TnpjGCa zR(}>9_}RBq`My;J+x6gQvwdb4t~GXHcF^e@u-Eg!XMgL`eU8z;bYOyA_Uy~h?0}#Z zBY0@;lvP_aT?*T)k!{^lTR-~H60h^-(dKi337ABrR9?eCxjI(v z?`&nZm>#?A2?X=&@+)3^obuee_IuXNynsh3XXU@Ra_q%%&>y;3SNzD>2PeAnIi&R8 zd3AZnN@FwVIC1eOOhkC1XJ@SJbsKVdB1}Mdl8k?n(^<38*Y`exl|p%s zk|HUt4}NnW^3x7mfJMvO&b}i+Ot`qrAh;J9yZwk2Lg$QbtQv7)t<9N(h3-~TeE?FC z%$QmU6z2Eef51-9lR+cXT+o;8EI+d+B+{i9O-4Y|bQwc=LJyMTA9oa0eX)lTYBzAQ zJ~a|#R8XFQrP%Opjhgy!i;1&zZXW8T`5VQg(ggP%aovnsms91Z`Et zc0#XSv9oR9Gdc3%*^mGh{4wj8;#EvqiNsW4-S(h;l&=m=dUO6^?h3$3IBDq)aMclDo);j40V8_)mV6 z1ZooLoE24w0hi@Yr9@qSl`&Du1|=jt7Fb{jNm>P5jKP-|qWmb|=yZ$VdgUABLl;JzWS7<`HWfdHvi zh6dU}n9+Jbqs{irWLb%2uc9m$6zm#O#qUKGNY@AN-0%^yU4^AWSRF#3O|TLc13HR5y6$~3)*&;<(!fK$PeDzU1YHMC?tfj&a*FxJ55}=80kU%{ zF2KmhHYU!>ouaKnQmmvcxIc47-ax+;BFJ74MN{UjzwOKULwCZ1|2g)i$*Ukz!u9Df z&Xez@Iz6Cas)>b&`CCl_>a!#772n4#O6u)6n6US!U_d_qkrg_izwzig52jL4&0Krt z9f#Lk*eXLAuXT!nL&=-<6P6HSG%kv;IW7*bY3hPQX>I4JD`xm;Ge+X6>;#KX(#1fc zzVUEYq`}R-Jy1IiQvj_?v3_|JZBaCTFTE&7pP8S4T-%}*zkFjE#5=P)gOCozqv1+% zoOah0i)v&gBta}o6)7pEeUTFvmD3XU@9)Yrw6);OuScpb&-YkAPdg`sc8DZglYykB_RSeub zF&!CFXd!en60(Bk z$l7P7(4PDWJZqrwCu;_kEOUN$kWn#PAq@mTji&vCVnZr-{5{JUJkl-qV(M*275 zJfWrF7Y^$`D4eYPtUqk+5a8k9Lavy7u9JhMj1VL+ z^nT0rCRc}{aLl8x_RX~jVj8*Tud$$G98-sxs9RA5(tkX)Deec^{y3;ccKXFu!>`sB zwo^maEhku}4*g}GI@YnHo%wy0g=yw~GD(SpF9`;G@{>eED2i9Ra;<>GJM&x!yT}CK zv$}&swuGFUzl5>`%;zU^EtNC_cWhxu2O@eoR~3Ko=x${Vz6@L4TK+7O{lo615T+8w z!AC6M=*r`so#FSOdcKo)#$90-Jy5^o- z9H*Xt$$am*Md0+oHj`0UhKAgG(q~}6-glw-y~G#t`3HG##=H~469x-;npoUwatoR2 zcc1h#nuFYR4rU2{Z*ZZU>f7a!3yt?R?i^HOMo<_BZfy-8eEi0RTVmfaRGGKQ5V6F` z@K!BK7Mje}0k;vcdpH%hURPrNc*BUf99 zJ9b;gC(f1p9q^%Tx!t!N_~Tiff4c%IE2@(=zM87%4Q?n*+0GTEaY$FU=>&R?s#k-y z2g;zXys%5JzbrRJ@FGMa!)8&|2kC<&Bq^9-@_FgtmL}A*68!cSU)=G+*u_>tqS967 z;Kk>eS68B#<)~E+ z@LxW`s%65BEE`X(5=O~=sCj#Zv}?use&_meno>$v@@4Z&9u|m-|ET?bBQ6%0+nd3s z^z0Mf{*CWMK>C~^K#GTtJp)e$le@#DpqanYLs&krM<*t0`qS2>UdPlb-S1A7P)h&f|bg6u$;qM%Zmb?j9VxE7u=7-@BNsvb5E^MF{x0w$+)QG$aV&^ zZXwEs3)YDo!nRd9IR$LZuVCgH+*Z7K3%dJcbwa5E5mB)Aj0v`I_6{K{Bnk38YhY0^ z*AfBOelXULG|Qs|nstWY4Lh3sTk{A3E7T+Ouu``km5hTkB^KBu^d&lwEGEt>*yt$f zb|=a(%8}r+`<;aOO!0~+`CFGlcAS)qx1N+`G7%D{PX7K|xlHE6k}1-Ga~qTNdSNRc zf0vkPaT&f-C<3mm;lv zr{AW4G140W%A_tT9?m7T78C`W=K!b)unqZzSHc-UJO;)j#TX7Bn|$2!bCqTOD-LIf ze2|e=m_0^KoVf<-%?1(^psPq$<t?w-z$k6 zgQQ@+#N!dXlKXFZ$oBEkqHr;yF`zDq)CdXIT^6PRO3oZ7Cy4&!KkY`$5D{!pkIeIz z9(`>^7CbELi0=up6M#N$@QB~irI}`lCx3S0BISQO%@Q+dcPvx`eX=DO*6$VydDLe{h*?~Inm zq-1gi^M*Hjo&2dm!-%ZpS8}`Rq0aNpO7b({^7D>%#mM%80)cN|>e_xb`fe6)d{;O!5%26`!`VaQeB3y-y^dDtT!B_pPgo-B;lJ1 z>eqAM8D8Whc|tsOS`#41@iuDXd8!yQZ6_M?U2uC2UDT2m>jhy+JMZ9Ev>w1z^z$W5 zbu-D;o2jd-`zBRhrQbi@&?HmDk8&SvNdbxN*DUM>1gC{vFG9i243mgM1c5ui%2eY9 zyi0G#ASrwz!AE79FD0;|8xeUDZ3jP~9rUiuPY-`&wPt2<0#DWv1~HN^D3;w3--zOG z)e)m^L^mndMaB42879J&ByXN#r&~Ds02Zuo3I(A{=Q*qE-$A;tQ-ysH}@!X z=Fw&MlWtbaDKJ6%IuvjdfdmTn<7#E)43CGP%5mppxj`i)7zFZ4n~SMJ;KbCU+LyC%)GWT*mc;bE zEnxjp*WlCr0qW4lxA?X?YS{woPR)XpVC^GKTb!#D9k0#+OnP&5Ss3C!waANpswGc6=Qqg#Oc^x#bx6UG(HX$7Tm= zA{yZ}#oPD5^8OjTD=ZunuMhG7%G9-Kg}Uh3gc%`G1v&V?GAyYsl6P99Rc&pzsO<*9 z9h<_~j77%cYGIkPR@Q<*{nD;~L+|g^zYu>MOyx6b>EwNh&c)}rI~lfa7k2WtcQ zWi*`J)(%-|N7Yh`gQi^HLJZi-*pE=Lby=GkfsW@nCalyXaZkRGs~BO=Efo;YP+(|g zljLF>$7tkCB!auN%AzC9N_+@Kh>Ir7el-Kwq zZbB>~@xNjaWt^r;@`Exr_FsUPa-tT_Lxr*0VpcuXIz=p*089mE=O*MxpqOXc8fatH z);(=#?#mmCljy3@2JtfPOb|3@{TQz9A?(44U+%t`-018yFIR&Ohx?n=(D_PE$wmVK zN_;7mr~ECRWwRhab2NY_#7M(}FbFi!RmJm>$2y&;X~yGpCMHei-$2u4UVeG6P4zcl2z9Y9+_Wq@XZ8IZJR2r&MdOqC#$L zK>9fM8N&3+x&Su!y9*%sg{7`dbpN5b*>R8m`9>N4$wvjX>nF(Agy<2M=Tu(4g`6KI ziM6Ti?Rezb--{kT3f$0^bv!!R^%tBSX#ESn&MgWCuvphd_>#fH;`B6QJrPs28I*v! zEy%gr^4e)vJaQSR4Y&8!Tv~EbQ7*!t{yt>g{)W5!^YIn&8j-^x!t(T*kDR<6+lt(F z9{{-hM8>M>Je}EnD;i!*t8KNVIxg>vxC;U(fPiwK?4iQX3kq-mz#*8;WUFCDnR-Y3 z<1?F(Qus&!5j-9mKoTcwbO$>AyOOED26d=|*;j?5a>}5DMv^-01@dE{H*?+fWT2zUaLssK6ER)!Zc-r;ulz&_;4D9PL zFat?*{w9I*oJ(cAo;g7b=sYOVK%Vaa#PP#1<*|G_&wEbKns7Uzm{^QrrPKc7)MTSh zntz`0Qj&QXp6n)m?hR(c7F5Ydh>d8h>PW(yZE(%4? zEe5x*9aQ&6o7gQs*}#X#JO36bGjcIDJXQ43j}~p{1L{&4YnUU7*%;yU7CQ6>*F6;a zPEsHZX({wLA|MV+mz{Kqq)rK8{WBH~tBDeIhJe%34EW9OG*eCfka!z~1@Fsb(2fV( za9npZRNWwKC98|fwhdIW5fkmxVw^nJB)7qU-)Evx5Q^Z@i!G8Ewebno-{`LN|)uP2-X9;AtmhX$DN#14azcxm?Sd!*Y$h`fQfg7R#Mx+=O97b&i@=@u$G^ zKW+jkrg>Q-wKg}ZZ;HWXn;AN81rn@Yb!51`4JAnZNwkl|&E#0c>8|&~QSL(0Xn+CZ@v(D^6`H zbn*wC_d3yJu(s-1)Ddl)^_K=;*i#}Yt|?vq)r&bICOb1|%g+f}v)|!s&->ST|4Vuj z(eda&1l<^YkN^Ci=oW?V8gT$dk~?Dhb&!9&tC`KFmirL2$VnbkN@P6Voyn7K?)Il&Sw04 zD@*HrK{pj961;?ucOefa)%H7fQhyRP6(V|e`d8)qI?3A}I@;Lj!+Q_Y9!k7-)KCc} zWWO{0>=GeuDjb139s=-W;uFi$ayK!}43leVTMt|M-kJ=@?b_fg5h-@>bSCX9(Z(%4 z>nT<|Zf07eb3+pd{tGHT(ENdTvw+;IAjYkfN!@nbGL((>nt5eF)^bA1ja@U5CZ<&O_>!5tI{5IsyV`6{y`uf^wFH|q& zcBh4jc$|Ax#E!Cd60>mueFod|W|{QlmJqCM#x%pizjNeMpEISItjYu4C!NgfxNd@#vMP12zrbEo*o*kK)zvk?~Qh0Tv*L(zjgEfaa zKb|qak-}RrdJ0*?*ZjR*mMJxpRd&~eyexa?q9j06+JDly`(-N+MYjcEsm{bAp{T&~ zV7^x3$QMp$Xr-}%2jSeo>ug~_A#I&+!pTGB{2)3GyB72sOh%m5VLexDIRxSt2~M(h zZb9c;trm@1H#8%_XL;{8B5_0_k5dab!mvRn{I|pm+?bD@!VPc5M9^epNnz41!jBqI z6<^huSN~x4M}F`aT2QE?j2b2p(kwg!ZUQVxfL1Y}VpZPIRHr!gs%k}KWO-Us{U~B2zq21X2u24H1})@C`}YV$K>dMI zD-CJ{!N2_!u=LJRHG;^AA){Z3W5OR;8jO$@TF|j}BB-|cZ}~5i$R`wlN65B#X^@UW z&sTq!^&@RRS&r?m_OJ<|#L&CkZ-QnyRr@LkXUJBW8whtSbPPxXm?;u|5e6GV%y^MO z{6+?Cy`k98kpX}*zp-9%|6D*VM15JL7^LV*n<5lgL!A>SB22RD zt5koC11>8}O$SxscT3taZZ2fCVk~Gf$=lXGtSIL3n*9J_SqKhAM-G;f2tH0v*-rqs z>-z)y6O0UXus8xo;(p10K%OG@b{z8+7s1&Lovw~dyth8iwZKylFq04H zV43QTg2Ko!b5=Ri4T&@9)RK!EMz~lGjFr`pgjVZ){=PBc%hc6%^i7fU!o9V`!E^Lx z&`th_7ev6QlmzF~$zFy7q|z1)l`#|fJS#jD3j}PJ>`=@EU{#yX1tps_h;%N22rlN7 zZf?L>A-?#3W3WTPO#GA>dlsO_sfmLpbDR;mfR;Zx$qS<87899M%N_*fXHd!G~hPSDFU>jQ0QqP-1oQepLGBu@@VL zBk6EK<=ssx!NIm#J22zwYVx-rQeE4jLl3#MT0FzG{A7wKCVo{aPGXkDZn#&XNaogd z0mWbPKo3hxXn@NAl>U#YSyqDU$Lsw&%j$~ zuK`GNO!dl2E%^3^o~}W;+@)QA^cCbS*gko~d`il`N+hYU#$m_O^)C(3bsnLIX!$b-n7VQB>`gB%&vFt2)s& z^2^vKX+>X+a@o>%_d5V0?k>SUrv?+6>@f^SjPwAk!)A?8SrK$N8jmIL-;=$-&4&oY zAw$J9Mm3AeEHyAE@F2uvR^!-+#t*aSA*nn)aL{3u*AyWmJXZFIxG0hS?(xxzrF>+uZa=-> zSdkNqAETBQihdg{2FKxlRrXU^9P#Y{3$2NdgTyAB{^3WsLE!XHaW29tPvkaHJC25v zdVtKGnRWZ83D>5s^(v#|dmREtPI$DiSi9r7PJUGA`nuee|%FJ`I8$(L8cK*70tALl}gA*L4 zGfG0s!G5wqJM^~OT(yV#jQ79&OEC->Obj%=Y%Km-o|iwex3Lm7UN+w?>ARl;!!s%LqxLH&l=4aU`}Rj9e804lG< z5CFX|>TY8QMhpl@;&0FpXGtN8Sx!;|;Ups;5;o2k8;ccsYif5a^>Ns)BHqa?Rq2Y! zeqoT~PX1$hKhBM+7K5{Y`Yj$e+2H7yL9+tDTPyI z)>1ej!d9iBYdG>G8FtYVZ|&-EblRd@(O{6 zk{$yv3A3UfI4z}2^_6o#u|k&bVg3FmqnMN|a?@Gxhbqt$pgfCGAR?{u9TV|4onid~ z_2?Hqd*e0RBlarEMvpFTT*`gohsIL&6dk|Mj(yH-5zwI)<#;4aG%Of)j2%C`|I zM!TmtXpG_YPzXG^^edR$-v&)O-(}_>v+`7e zXTFtEwcrq!qk+9nhG4a0uCXIn5p%gtgjQ#Gn5-e@@8d7E;Vwl(c2{#yAk3A_R8VNEn)yvly%~PyxMZ({~V#1^S zs^ntYoMA}4Nk=@6$jT~^Ar9cNFBscHho`c#nrWg((a6qKJ{rQ2J&#hOk{(-5N=Ubf zZ$yMoia?dVShPKe4YvAhizAtvZ|u%srp%A{?p;hQL?oaT(}*vUFupAeQ6#SSU=~s)TDF<|mds2NtUt=iBNzN(`+WjN; zfj@rHmqKXr$jrD%T7bKxCu<=MoS;bl4!mbw;u@9=`Mm-rz}~OXczq z4Ai?(_%_2MqqM)TTDoeH=j8gSl^n>#*y5cj6^#&6LY}zKkI2=|pyYwd+D{R7MhyEj zeKg97%Og`afVXMs9R9xs!5~@t_KSMVK%Vd{X+fHMD|a}wTYF3ZpfgO3tAx|O8-SG^ zR!~INsLi4*|Cq=A(^A5zIqoESbza2uj^!5UKld!{2#3%Q(&y6A&y(faO(*=)@bdOS zlK(oLse9K^qS`D|kB>6ptw0iR=;;rz?3XRvHRAw%)8$G3>bG5+0Bpnm@BWXupCUa# zbU?nc$+zQC!azNa34l7$;Eb|ymk@o@7y0o2y7~&Jww7q^B)Gem7D8|-E`dVu;980n zclS~X1S#&4;zfcLcQ0C`xD+kc0!0e6K%r>=^xpfwx7N#AS!Z%G^X+f;-sh}2d-lu` zB*Us*ZqxkEON^nJ{kYP>zbC*id$78Ka(@-Xk(UN=Nq$>cj5&a;U938O^%eH(tq5Io zV!OkJuVytP4()1Q$8{FB1og_^LgzCeXSU5HGkBCByy3O6kWV_ma zThIsjs>BzzkdfQRku*@ZgmOLQAqdSMcnT+V8Ze-X_S@_7@e!4LX&q6eDId;``Buv?BA46M6?#T)Cq#(JlIpc{ zEQGD%&bqJ#H1iW+>K-$Sd-bF)8-826awH{$8NsHu%n~Vbd)e z3EcOKhz26?5qi9;=2W|3hZv1oyFsD1D%|cO6CI*9N zwd~R6JOjs;vS_XgrCKPS1u^bLA-(*g$mP5o8lFtqYn9TLNEZyinS)+SWBO8RifgFt zlT`*z8rCM{U=#p0g~=q5rT91mfO|wp3CC6qR8bN6SvMFFfFa#&gs+>+yNu5Jr6gL} z-G9zMpW=@wc5Nv@|2RYaEK651TSwpOkr`KD)!@c=TpRAzX|5ry2IfOuCY!zas zG*>zu`r~f!O1q}j{udJ6GTc1lY$%`R)xRAJ2m)Lf<#5imHrVIrtj{RXQcR?tDuq!H z5PNCZJ+fHF(tw?%0FJ=R9FZW3-X&{ZHU3Yu*Zkwhrjw(iIp=Aru0%7~p>Xp!W_eu~ z6%anMao&mWqoSO145lp?XnC1O>MRFKbr{s$0s1*guB)-R&RyYl#b<@oH*8EWh^+qOP zBw;F73akqTDl!e#_E#;#%`m1fg2m7keoCPQA_5ld=&eC=4atJ|#a2eAN`|jrJz2sr z^xO9vDHp?bbd`uyiVux!Vc5kW5H@tHs%94Q1 zK@Q$b%DF3nXW?V5)?sdPdi6^urtSOSY&t2~355?UoK zkqOvVYFtei+5@o8C6SUwUm!HGA~goPIM-Rk!36SD^&Ot7g`11YrL$VF`80>QYBbzb*f4YJ**_F@U;m{@J{1vw z)a6ppZ6NIsZ`?_k&cx`G>TS^BUS{QJwvi-Jjl%m&&@&=h7DXBtY=+D6ZC}48-MTPz zDU}o*;2<+rgW&suQ9yOZ%|{fQNV9&MlvF3VRD47U6KiN8*K4Po`7pm`?0?z$K9IUH z`0x!|7l+_MI&tPjah{c1=8D^C&|*DGZIdYJg{*7GJw@C!a&(7 zJ?y@ifm8E{DJ7OZR+^|+y0^if$CyDy;3=GjF2Rbd1yBj2$i`pM%I0~j6w2D7P%QWL z)wUczz0tgjhLxOPI5|f0GV6QY9%IfdU0n^_4yp(;LM%?-JIAM-!sG<>F?57VeY+Fh zS)X?=s8pQkq&kPE1>s()wU&mP!aRSe6k6YPW~m6UG(l6zq;+ks7w|NB0P%$1F&qO} z`izhtWAMn)eyHz5_Zy2CbVNAA!M4k1%R4Y`W9`s&NQ7?ym*I>S)ph{4pe!U>jh#co zOmWlVEoVBPi>Voif}Eal?Pv3C&W=@Uc0a&GrDJ5bq^CQ9E?1Vj$?y34qe_@;UBu#I z^fSOSqWA}}Ii+<-n@_W4N0aEFjHBxD*|$siutt`?WLfe9x`kqvpJ25XvURs~O)@1X zM^&7g%+cHV=>zzymjrcFE9#Tf@~nVF0Xz5@gQJPi&@45Yxv0_w2Kc2Ay{2$%NiU`c zAptReT7u<+$mP2R7D2d7#r%S{E6fg1kg}Be0MZiem12sUQV7;`B&~kWDOrWLIZu`X zrem@G3haL#e6qcX8W)`ayx~{B6BbOoTm! zpIw}2g6mMW9fDy|P6#o!&{U`cmIR$CCQ|VKwq<(ZFfAPO5J1ib8X^KTgn?HJy@O?B zSoX;kO}&N-t7ocwhi%-2IaX@rofSlX!96H{p2o?!=#a=$34PY>ibbWD?keoaPj)L9 z8-jOI31M>hss@hn)3@NOJQO}w8i4jtqR5mgbCp(+UdYtyt}lOqQqdZKNhMaKC=6b$ z$5)KSfL8++Rbl8QxSE9Ih;-pu8i@kvj3oP~s<6#$@WhRZWd#P{XP#stgEpjrXUXtD zTb#bbZ;!YKn{%!p$r&Bsvau5l!Ui}X85e25oZvLU7 zb{3meCfty&wJx?4Dh-x!;EhzHG!nq0>cEJ0d0uEg1`eZh{D|k{#;Lvdg%Q5V$f5D| z)q1EG@X1%b=`v>tt7_ths+$|?VQpQ5|`% zfND*!KLGL8VWaARUvqJ^q46Uv5e(guKPO(X; z4lF6xSgLItC`oBnFacfiLOCtAoY_Mx9yJF(O@`6g|F|9DMs( zDE%D$Xq-Qt-h_I^TP3Tawa3)-L%WG0R+)fz*kG;tN@OQm<)mwlv%5eot2nBtT3hNt zGdP2*LGh<(MV6-SrJy1p>H3^AD4KYefwk<}>}ne$FCG-YNOkmjR)?Fs5l@fu#MnOD z`K|jr2+;94;YXr$3CT>~tKyw=)fcPIDU##OQ@_0bc6+P}R=_cwT^#RQ`F*vSJn)Zk3J$h-`M~vyHcs;q6;HmnkDE!JG&{(63w}ycf zwT3(U?!igYu<--(g&pj8y=zi5$P$t8kBe)Z18j~JBE?m~84+Xnv|G>+XAnm$JL$HG zh-|j04?*MjK&~(5;RKs?efz1uvz{*Mw@sQ)WkdAipnqg2jqTg&oq}$VDrV|Yz12n> z1OpL~n_E!{Y;8`hGDHHp>5^Q+jjhXlM9qRG~)j(&}w-NfAfAxov&ByQ9pel)vZ0$@+xf zQ5S1k&o&#Un)AGhNjS=DM^#P3s0&kj1 z+}~Q!$ZC}~Y|{(tp`uM?F_q%-#?#Txh9Zu*4P#~N?IRaJl91N>A6bEN%9}l#tdxpb zMR9U;eb+?{NxsZGuI~u&UQP6vhY{0-Pu#5cDiE{a+Fr+lIHj1;vZSc9bqd@>Z7+`tgc8_ zeF}G%-KH9Wx( z#PrgZgzmbD@;H7pT)z18K?Vcq%1-dgToSq(qTZd08JT6~O50dz21v$<2E9zuP__e% zOQUxf%Iv(t{04DHs;@oRHWDJ%_{=j2(Y!|zBBl$~I`$p0Z-K=zUf6s4?L4$)m&~NR zMYl=lk zVJpklE8=Ooekx*m_`Lzm>XeSK!_}h&mZG@V3ylPSLD?s%71GrxQ;s-fI3(Uj6CxcKVi*lN==5=9?w4{* z?*Al!LIQkOm~2Va_NQ6-*|W#8zVRpV&wdFYi17yREn)$d!TlizF0u+8f7#3ysdZGO z-FF?-OG)9Ca21p}G2`RFliZ$EIqOn4%Jy|qd&;u_k7`j_)^DCLYEq7Vxc*MkGfMhxZ zz5p_j$?A9rite8c_RE(_zw0#_P_k|Uk}vh3KAd6+eRi4QeID2nBXhGUh4wTyaqvGe zjaX!Jtm3}ogy@OOraf{uXvF1-&Ehz7bB|<}2Rb9w2X)8%?WcLI+QYK*4)lyZ&Q#pD zk1cBFS+@8ttg#Hf#};`%AP{WBx1-c=IH&nz8AK;Ovl_bSD!0gf<#79(9TV>JLll3W zFwT0DEM*AtF3ChItZR9hl{FDM!^Q@{g|lRX?F%d^#mcQU^#xxrtiE-etGK9OmAgE5 zpf_+&Ubp%4n&u(cb3TT8S(LH2=4iBZgw}!f1JQLRMF#FVwq^~1prr6cHlmXtihrFRvU`&J9?V{Y=wXe`dp9YjYuv18Enpu@`uvNt6; zV5T>FCUKR%>gH%rTO9XraAgXhxJ+5C4(3ZfTC6$mtSd9VqavmVlU|<6L?e!wFS!i+QP5X|UXNIfd(c z@m_~me+#`VxsB`zI-NQ!@6A1vvFb0qGp(h~%TqQC;vVPKI|hI5JaH0h>Tu@5O&YZw zAKeGh^kJu7FZfva5Cl1=O5fIveQ#cTUU;FtVSM~x4UL5&xgnvrEBIBt-?c}3y?}c4 z`(}8X323~LeO!??xSqT|ITU_o8EB>hU3GbLUafNVV?eX{fQO1Iu+*Km^O!6DYo(6hWoD7-J7r>8CWX&TZ)2ruodC#q?1`{b!!Q6a+t?yMtnLBv(!q`QBBOBXzQLaZ7 zC$QXI!+u(&KR%i2U*NqT-B~=v0NsapGd6w^o8Y)ky3<_<@STLN1QBF!-xA)$11730SQZLiQwG{f?UxTU6JzsWxfXM)HWucjrXmDq zH0lhXEOnluRqh%Xk6u1J)%f1}zWH;fCbK$;w5eS5!|L7!fO2 znE$9kyFif%anZb2K0tH<`X5YkYq^gFfNc>W5QF{M^@5>vn_IwDl*E8#?A<~uGH_9) zA4Q1A66^l`M>o)>vt+7vkMcQ4-y7mZ&q4kHVnw_AgS0-OhGT*)R?cx+v}bWz2g5Kl zp)nxHndcGo!3{;#@uBV1uj-u|-&D8&^tGlb6B;c2gwm#y;TfPq?d#`7$#(QS*Jv(w z%L7F!2YM3)mlAGO1`*DJKo30Y45(#Q?~vowiSxI6-&9O?;#g0OvcOnj#t&j2HRv1G z=yT0ySTIa--n}S7IYXBp?B`;4`1pR`cYZhXBvGm28GqXZ)9POAEbW%OMV%o}L3Y^3 z&`AfzwJ>~_+za;0+B8U;S40IajFm4r@e9MigbS!)%_c32EG$ml7yO(xR-HYEC2y{m znp!9}(@80m%(pESTP-2GWrnI`e`%14l2BhSnGSePfWckoN!)exDvYR3G0p`irv(&A zoaOmv)NNycKrr9qy^)4`GO*%JpM5nljYF}~Sh-N8cHU5oy3Z6jNqw4mDI zKO)}Cok94EfezT#CaUsqbf{j(!;Lr6tLrF7`KR5!?W$Bp#Jl6e1s|+<_Em+O_Vy z4p76X$g6J$ zz*5KnusPxaFu>juh#;1B*oZT75Q`Y10w_uroExVXALGTh^~LNSm@$tLYIkcJ_v+H{ zE89=EfzG|GI>G$nSzdUWMok+XclXJ0Hxm9p@L2qqq9#xIBx3@(>w!W&tcgxTDVbYC z6(UPT(knp9T3HZ8w_*gy$`~$5l+6eRTvxH;Q32Z)$iVx}{A`cX!uqmP4H&9fIQH85 z`s`DQXMz=`(n?}24n+)MhO*qpb z=6F1UYG!!(BAWJ4WxAGNr#uh@`#?154J%(Xlqs;ew~T+zkpL9{#Fk|plF=T{jFS!e zVFAX0G2MzopD;Okd;??E({;%uDroqJ@X*gqq*~emw3T}6&M@j^{slOx6rRN%zM<1L zxGwi->467*_{VO{qJs=dha88}LNMS>IChTWXKX^imvPGA7{)CtGFhK=%A#UQ{m+{p zFMu64K<(^?mxVst4-#p2oE6r=3i3t8Fm%9%JN*+qFDY~hS3CId!lFpO!2j&lY?Uz7 zp3iCPiSpbF^N@Gm%4JksCbkcRnLH8=7iX9}#<>0lrFKs3^aS{=TeRxXh$uVDl)JVI z);%M^c7TUA3LDqJJ(}ed^z}pq6lW4@5Sx~y$qMbeQ!u8C9%ssD5@sfbX@?mx@kM(q z1XAvllVd^MJfhF&cL=RXD``|IkaViz)1tEjDd+iynz}W(HV_(tI1T!z9X|Uuv+j(H zChLIlW5cP!H8l*+2}|Bat+CNQMbl(P@BlY4LSiD3f+l@9#H=A;eTFq>IOO5&n@<>;gYgt2S{!-4ZHMc&N*KM^KWnE^cBsTGP&=@)*(n^Rsgd zN)c0_WC-kXzyTP(0XcLKlOoxsJFQTEUZp!HO0`EMiG7$;w*-y@$K#o3>M)xMwDi~? zk~zlf<%?yobkqrdREBnscmWV&pM&{pL@z&_(XSIOSSMi>nGyu1KrIxw> z`<4rjVYZ!E6P#)PD=SY&4WH3b^f0q#n+hQU@if%dLCQFx!;2)7<`jsOBQ(4fUDCCNo*(`eJ%1sVn-RVNT|1o7S8~m#6hLq9p&G+!#KY^XZlOZ8eNq;(Q7{xnm z!j++F{8dG3R$ZmE+~_DG`%%1ILk`qnULhuJm*Aa!2d@=leKT`7uY?Nw0_))d6uS1% z%I_0yW2K#sn@ie!OkG-oDE*s-EZ@;cRO+nheqz7UPH$%VIadiba`pkYFV`s>g)5J0 zNXE#yKn%8ul;)){ui8OY0+0RAa}etKF@uo6$Sy!BI4RsrJyIRySDHje@Ugv=GnJ{O zAQZ_-V60Ou(K|=LQ_+~NZahj&TBFvq(PiyNFN;*`1!UU2tY7;`~{R`lB>B~4kGmH9m$|5tRWc`C zqYYa6Si4TF6{}#7Ixh!wU`RnJ)iJA~fbvu7T15h$)aiJ~XOvdq`$f1(c<~E9e|#S;1ydn5aw~GaVkGA$PM}}BLTQo;D)ogr+n3Cz z4jUP3ATykc!?4pg!X^uzts$%SuWt3plekfGpd?CzKepCiN0J4` zdUZ)>v!qLD^wB9Qlsp>sFnF+?zmv5}UdeWa>JFiik6Fb;be;Vp-$5qJ%2+{a%vZ33^v}uL< zax8!?+;CR9ggj|nb5@}7PG5b|LC(R)#H_)H%F(fAtsB>=g{}OM1YREc3FhrG2>1^f z50dsmo&AgW$_N6j9@=xukvL)vTjsodYbRNO2%i^rjRaXWiB(~cVm*{GtYgw8#Lx(x z?OWj5Q#)az)GUxTYg+Mt+%ACYG|>eodDa|lQ9}MCh)fL2c+$L(8uKT#0I#usZSJ_o zrlU;|J6R`?m11M8#{*Yv)<^3{b`+K21l)pl{#A}KOeRCb#ti8q1W;q+ltdnIxabqd ztNectn=J#M01)Z-Tq*n3hyciy$^*+_RKAg5wo~fen*>clL0)U*_=$j;P8C{%6<8k* z+FeS*gB-FN02SrP15H=7u~GlJb>phm?p5Zb=HB6IFd~t zyxoIemqab27aoy3nE1C5*nY%I<(8osh-xWIbK=Zq6uOL#`Z= z*h}x%DI!Y5AZ@`v{J>$11OV<;EoGiH-Z)NnXx$6_#Ja3p<}tQRvc;kBA*rHJ8~}%g zq88i1KXj>XaYucGc)=e3*XjZ^WaUf13L-Ba*MV$miLb4fr|#P#N)! zqZRNqddYNt3C@PSY>d{L(LMV|ZVC&?M%xgaRM%WZYaEMw9CiCFQ&RN-Z!i|Fm>_s{ zMPV{|C(WreA*R|k{ph}X%_gS$V%AIV@}HT9v&4U99yXu1-yg(3zjrvg#WN4_m83x# zdiTwLgpI0L1wA+wrgJ>qXZQSaU}B#ua>_0Rp$9-O>#?gW#7a%T6APvOnx~^o$ANiW z8@5}`$IFR)WwP!<-mu0`tjLbDXUl_Ddfm76$E8f;*xJXhek$ozF*r>;sd!X>P*P?X z+{{t1si_1od9E&m)ky4!58VA3g|VM!gl?j%m^>_mo#kbz^!)YV!;Cye#Q9{rQ2Zm7 z;QCbA*e5Rol?y=fF;(qltVRcv`l01QRr!GP&mr9C#3~87Jghh=7TKY zgkx7bVn=OHSr$-|-LKQ(OpWrdbm6M){CUr|I?8yueuUymWqU^cv>tqHAPBENTfhJq z$M6J1ezz7^(RRbPYG`WbF1`Q`KMFC*efjfiQY}@}oS?sCsTd=~NVl&lJgf3I&M==S z2!vMS$Vd`*o|h$8ZNH5y0*zVxm4=%UwNw)~&yTOIYEvO(^EM)F?gGn_rklswa>@b68r9nwlrPOZ=K7++rZ$)w z*;gzl71=V=%#>$azf@?;YNY9j!YL*1RMN?p(QCReBCDWq)EehQT|lFNb96H{}G!*FZsegOgw(T|};zfowTyDp;1L`kIbdd3M%UkQlq60}g(^WD1|i3idH zt^IN_Jz0vDP_6RBPWE5vEm&6T_p{uDn$!z8g?dwKtrcVLK^0eFF)eOgwu}@a2WrX3 zc;sUnJ=N8Fshc%Pu;6q)q8(VN!Zw^h-$3Ue*+zGm4c^uc zcDk8nt{}wg!PDBcDFZYUsVjjbcWi+En?MNGkO4Z4Hpzg+8J*U|+_B2?wUta`wLZ%= z=uRO!;sfLZ75@&zorxjBT;YWUo*)LtL_<@U(QvYh{IGf)r=BVak9?eqjnkv4Spa${ zF;6pwPtQ|Nih{C3u&M+6^24ywMuS0kq*^c@WpXP4JE7pp*>fTSH+LK1XIw`Ei7XnU zp!w5B%gAGc7{^vh&4+ig+Ps4nG%-DZm1rU9u39k>L?ps1sQrUDy@ zpb|E8Vut%@!pT+7c)}w#EovtdjK*eomtcCrIlzM~MPc}j2VERwJyZ!BLzSWpivg2X z&h|s(N;03(P3|LaUK0)oP(Ij&E*#z-Y;iAIH0opy&X9d&Ul3@bkQPj~V#B$VZF}R( zTsIFK!i4XKW)%1Vb%}U@EDm+&a>;wS$&mD2BMlA|P=tralu;2iCtZc-Ib$&`{^+Al zIt8w!rF@5t@7vqVMXqw4P-YoX7a+jLKTzF)>q{db`3r<-pQc1ZG>gd!Q3^i@O%N5_ zV?2Uag#H@Grj-r1<%wtjFrm%bvb#L34;Z9yFpQ?{jdZB-S&7QiWeu&wp?G=4G8U&Z z&D>MF))L_2(f3ft=1m}~aA#Qe?xz5H`x1peqD7uO&+%}btdWsF;%3=QW<}6p;u0eR z3RL=sSz{ZPxX!Fo15C6o=V{b%h>*s(`rjhb@uhjx&Uh-!k-?w_b2-66Y7C+^)0id_ zhDIkNt0iqlh;GBGEcsv0`NY{;%il^O8WA?L^Dql)%F9@B|J&j7j8NXat3nzz06;az zA(5lW2vnnNko~RboR*+IMQGuS^i5>i-EQX5W`?U8@l-1=tgt3y zI5!za5SPVxwG_NeMt4pzeMg@9JbroJs$gekb|OO+?aW&7%ybq!A@9N!RQ)93U-00C z4bUAw>5KbC+5}NULeUvnuv@*5Z7aJKFS@b`5M%^6n7 z{9pK`%+>EdDC_Y^x4aJ5ds9VZwrIE{Z7s4qIY!(y#S)dtXXI?YXlQr_J?U>L48Q2G zRaFI)2qSSxcqIAiieykB`dwC`h~Lh4MQO zaaYi9ahVFb#DouGwP<1*-q)LsxlJ6CBuB(mB@aWl@9&rn&2b#+{AMDnWpR$4Vq|)T zQWf4=>#gLWnc&eZGbj1>S{@x15wXm~JTQ6toF3{>i#6SB@diU=pqLq@CrD~nQfo7w2J@6x`pSusk;t~gLtz9nkSIFR5uE&cIjoM0B zCRAv!l$N;D>KM;sZ`mV9?(A@!H1e1_fVv6wn@+)=>@+=RC1l4(P-Xv)R;~Wdwl=RM$G#$RpA~@k*vn8KCk%`yyCCuCu0 zxXBp(jQV4|bvlPW{o~#~q2%y_q9svf*>O@g4}VMp4S?_8@l50_H=Qx9f8Lkto6*mz zXI!jeXjxpJw12}_eYtq@IvaL1{k!$bbYJhFHYk){tNrSk^YUMdY4Lt$*zwkO>a2>E z`}xNFi1J@Pk|JGmvsm_AVoIetVi9%1AjIf6ryF zQdyJbBQ*Q%)jXGHUncQJ?Pd$TOLW_PcZzqg7A+MqiFvK%AM@QP8MPHJ?_;0*IUWBk z0(tgEN`}}0%$F6@v|B+D*uewkElUk+aQGWF`rpBMH)KsNpZb@#h8Mr?iZS~=68N6ue zucTV9`r-DPg!`58))@YV`2fC*!EdT@{E#A}E%O?Ft31kQlQKKbuM$pf1iriv{pNn% zBQyIfcpKxPSsMWb?@NXiw`9xx@~l3Q3X_%Mw>x;%9*+qPw-l?Q1LKE(zsI_v_lkmAPkDqdVy5dDn1t3mvmA(J5n#NuPk_KDqx=)4*K>l)O|Ewppj`dO!OYK9eN3snVHb~A~A?b?Jm&_{Ff;3&m=byjksTH@-OBii`mx1puO z_@wi0>KUv$qF!i1gnPOSOJB`Xy=C@>A zJM$?sId^(?lm3NL&D_34?Z_hGuVXVfKSji-qap&<*Px5d(%3UsGGBUKtX}i0=IYn2M*E5UrKM{q`@!+Gr`3c@e{yrp zSqLF2Fl+=^{{^ZRmloS1;3%?#x0>117Zx+e7sObvMB5O0n9t<=90&iX`(ouXJ)s8U z^>O>fN}$OJRC~m(CN9d2Pd~cYZz(j$NXn;8%JMtG*Fr`=(qTLivDTQwFU+Ny9502f zV7U`3<6{v;ooOxMxt8-8a%5+6h49>9PpiF|D$hJ^n*@d@Fm+8+F*<>o?D!gv5ZbRF zNB77+cn+SQClC1(#nU->h=dQe#Y=&eRLtZauIbhASHz7LcVF6>!yl?VpJ6h(48x9j z2YIW0XFZC)peQoCei}3LW&vJRNe9_lHf&Dyzoxnol#l5ed0NCLkWT$ZE52h%8~ivZ zV~>D{b>d{e{7IM+O_%C7CcYOLiD0G%WSz(oJYFDSqdO!>DAL?ZwS{yQ(g&krp47xB*@0030*FJd%W6vlt$ KCUo_`pZ^B~>u;d| literal 29618 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 diff --git a/website/static/screenshots/step-settings-engrave-post.webp b/website/static/screenshots/step-settings-engrave-post.webp index a5a1254a5f106cc3b0286e4ba57a811cd767f236..5df9cbea59ae698ba128352f24692930e18b1b96 100644 GIT binary patch literal 17692 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 O09wTiWp4p02LS+gjslGU literal 15718 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 From ef3849fcbf71c8258c1ae3be8515f845233e56e4 Mon Sep 17 00:00:00 2001 From: Samuel Abels Date: Tue, 13 Oct 2026 01:46:35 +0200 Subject: [PATCH 065/104] docs: add image editing examples and translations Embed a before/after example pair for Invert Image, generated by scripts/media/gen_invert_example.py, and translate the image editing addon page plus the Invert paragraphs of the engrave page into all shipped website languages. --- scripts/media/gen_invert_example.py | 116 ++++++++++++++++++ website/docs/addons/image-editing.md | 10 +- .../current/addons/image-editing.md | 38 ++++++ .../current/features/operations/engrave.md | 20 ++- .../current/addons/image-editing.md | 36 ++++++ .../current/features/operations/engrave.md | 18 ++- .../current/addons/image-editing.md | 36 ++++++ .../current/features/operations/engrave.md | 20 ++- .../current/addons/image-editing.md | 35 ++++++ .../current/features/operations/engrave.md | 18 ++- .../current/addons/image-editing.md | 36 ++++++ .../current/features/operations/engrave.md | 18 ++- .../current/addons/image-editing.md | 36 ++++++ .../current/features/operations/engrave.md | 20 ++- .../current/addons/image-editing.md | 26 ++++ .../current/features/operations/engrave.md | 16 +-- .../images/image-edit-invert-after.webp | Bin 0 -> 112914 bytes .../images/image-edit-invert-before.webp | Bin 0 -> 112898 bytes 18 files changed, 420 insertions(+), 79 deletions(-) create mode 100755 scripts/media/gen_invert_example.py create mode 100644 website/i18n/de/docusaurus-plugin-content-docs/current/addons/image-editing.md create mode 100644 website/i18n/es/docusaurus-plugin-content-docs/current/addons/image-editing.md create mode 100644 website/i18n/fr/docusaurus-plugin-content-docs/current/addons/image-editing.md create mode 100644 website/i18n/hi/docusaurus-plugin-content-docs/current/addons/image-editing.md create mode 100644 website/i18n/pt-BR/docusaurus-plugin-content-docs/current/addons/image-editing.md create mode 100644 website/i18n/uk/docusaurus-plugin-content-docs/current/addons/image-editing.md create mode 100644 website/i18n/zh-CN/docusaurus-plugin-content-docs/current/addons/image-editing.md create mode 100644 website/static/images/image-edit-invert-after.webp create mode 100644 website/static/images/image-edit-invert-before.webp diff --git a/scripts/media/gen_invert_example.py b/scripts/media/gen_invert_example.py new file mode 100755 index 000000000..a728e0731 --- /dev/null +++ b/scripts/media/gen_invert_example.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Generate the Invert Image before/after example for the website docs. + +Renders a grayscale, photo-like landscape and its inverted counterpart +-- the same image as produced by the Image Editing addon's Invert edit, +which flips every colour band and leaves alpha alone -- into two images +under ``website/static/images/``. The images carry no translatable +words; titles live in the markdown that embeds them. +""" + +from pathlib import Path + +import numpy as np +from PIL import Image, ImageFilter + +PROJECT_ROOT = Path(__file__).parent.parent.parent +OUTPUT_DIR = PROJECT_ROOT / "website" / "static" / "images" + +WIDTH, HEIGHT = 640, 400 +HORIZON = 235 +WATERLINE = 330 +SUN_X, SUN_Y = 448, 118 +SUN_RADIUS = 26 +GLOW_RADIUS = 110 +SEED = 20261013 + + +def sky(x: np.ndarray, y: np.ndarray) -> np.ndarray: + """Return the sky brightness with the sun and its glow.""" + top, bottom = 225.0, 135.0 + img = top + (bottom - top) * (y / HORIZON) + distance = np.sqrt((x - SUN_X) ** 2 + (y - SUN_Y) ** 2) + glow = np.clip(1.0 - distance / GLOW_RADIUS, 0.0, 1.0) ** 2 + img += 55.0 * glow + disc = distance <= SUN_RADIUS + img[disc] = 250.0 + return img + + +def ridge( + x: np.ndarray, base: float, amplitude: float, roughness: float +) -> np.ndarray: + """Return the ridge line height for each x coordinate.""" + shape = ( + np.sin(x * 0.011 + base) * 0.55 + + np.sin(x * 0.027 + base * 2.1) * 0.30 + + np.sin(x * 0.053 + base * 3.7) * 0.15 + ) + return base + shape * amplitude * roughness + + +def mountains(img: np.ndarray, x: np.ndarray, y: np.ndarray) -> None: + """Draw the far and near mountain ranges onto the image.""" + far = ridge(x, 205.0, 38.0, 1.0) + near = ridge(x, 262.0, 46.0, 1.15) + far_mask = (y > far) & (y <= HORIZON) + near_mask = y > near + img[far_mask] = 108.0 + img[near_mask] = 62.0 + img[far_mask & near_mask] = 62.0 + + +def water(img: np.ndarray, x: np.ndarray, y: np.ndarray) -> np.ndarray: + """Return the lake brightness with a reflection of the sun.""" + depth = (y - WATERLINE) / (HEIGHT - WATERLINE) + img = 96.0 - 26.0 * depth + distance = np.abs(x - SUN_X) + shimmer = (np.sin(y * 1.7) * 0.5 + 0.5) * 6.0 + np.sin( + x * 0.9 + y * 0.6 + ) * 3.0 + beam = np.exp(-((distance / 46.0) ** 2)) * (1.0 - depth) ** 0.7 + return img + beam * (26.0 + shimmer) + + +def grain(rng: np.random.Generator, img: np.ndarray) -> np.ndarray: + """Add mild photographic grain.""" + return img + rng.normal(0.0, 2.2, img.shape) + + +def render() -> Image.Image: + """Render the example photo as an RGB image.""" + rng = np.random.default_rng(SEED) + x, y = np.meshgrid( + np.arange(WIDTH, dtype=float), np.arange(HEIGHT, dtype=float) + ) + img = np.full((HEIGHT, WIDTH), 135.0) + sky_mask = y < HORIZON + img[sky_mask] = sky(x, y)[sky_mask] + mountains(img, x, y) + water_mask = y >= WATERLINE + img[water_mask] = water(img, x, y)[water_mask] + img = grain(rng, img) + pil = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8), mode="L") + pil = pil.filter(ImageFilter.GaussianBlur(0.4)) + return pil.convert("RGB") + + +def invert(image: Image.Image) -> Image.Image: + """Invert the colour bands the way the Invert edit does.""" + return image.point(lambda value: 255 - value) + + +def main() -> None: + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + before = render() + for image, name in ( + (before, "image-edit-invert-before.webp"), + (invert(before), "image-edit-invert-after.webp"), + ): + path = OUTPUT_DIR / name + image.save(path, format="WEBP", lossless=True) + print(f"Saved {path}") + + +if __name__ == "__main__": + main() diff --git a/website/docs/addons/image-editing.md b/website/docs/addons/image-editing.md index cd006f4b1..586fd267c 100644 --- a/website/docs/addons/image-editing.md +++ b/website/docs/addons/image-editing.md @@ -18,6 +18,10 @@ what the laser engraves. Select one or more workpieces and choose **Tools - Invert Image**. Light areas become dark and dark areas become light; transparent areas stay transparent, so the background is still not engraved. +![A grayscale photo of mountains and a lake, as imported](/images/image-edit-invert-before.webp) + +![The same photo after Invert Image](/images/image-edit-invert-after.webp) + Choose it again to remove the inversion. With a mixed selection, the workpieces that are not inverted yet get inverted, so the whole selection ends up the same. @@ -32,6 +36,6 @@ same. ## When the Addon Is Disabled -A project keeps its image edits when the addon is disabled or not installed. Rayforge tells you -that the project uses features that are not available and shows the images without those edits -until the addon is enabled again. +A project keeps its image edits when the addon is disabled or not installed. Rayforge tells you that +the project uses features that are not available and shows the images without those edits until the +addon is enabled again. diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/addons/image-editing.md b/website/i18n/de/docusaurus-plugin-content-docs/current/addons/image-editing.md new file mode 100644 index 000000000..019933c98 --- /dev/null +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/addons/image-editing.md @@ -0,0 +1,38 @@ +# Bildbearbeitung + +Das Bildbearbeitungs-Addon verändert, wie das Bild eines Werkstücks aussieht, ohne die importierte +Datei zu ändern. Jede Bearbeitung wird mit dem Werkstück gespeichert, sodass du sie rückgängig +machen, später entfernen oder dieselbe Datei mehrmals mit unterschiedlichen Bearbeitungen verwenden +kannst. + +Die Leinwand, die 3D-Vorschau und jede Operation verwenden das bearbeitete Bild. Was du auf der +Leinwand siehst, ist das, was der Laser graviert. + +## Bild invertieren + +Wähle ein oder mehrere Werkstücke aus und wähle **Tools - Invert Image**. Helle Bereiche werden +dunkel und dunkle Bereiche werden hell; transparente Bereiche bleiben transparent, sodass der +Hintergrund weiterhin nicht graviert wird. + +![Ein Graustufenfoto von Bergen und einem See, wie importiert](/images/image-edit-invert-before.webp) + +![Dasselbe Foto nach Invert Image](/images/image-edit-invert-after.webp) + +Wähle es erneut, um die Invertierung zu entfernen. Bei einer gemischten Auswahl werden die +Werkstücke, die noch nicht invertiert sind, invertiert, sodass die gesamte Auswahl am Ende gleich +ist. + +Verwende es für Lithophanen und Prägen in einer Gravur-Operation, bei der helle Bereiche tief +graviert werden sollen, oder um helle Motive auf dunklem Hintergrund zu gravieren. + +## Ältere Projekte + +Vor diesem Addon war Invertieren eine Einstellung der Gravur-Operation. Wenn du ein älteres Projekt +öffnest, das sie verwendet hat, verschiebt Rayforge die Einstellung auf die Werkstücke dieser Ebene, +sodass das Ergebnis gleich bleibt. + +## Wenn das Addon deaktiviert ist + +Ein Projekt behält seine Bildbearbeitungen, wenn das Addon deaktiviert oder nicht installiert ist. +Rayforge teilt dir mit, dass das Projekt Funktionen verwendet, die nicht verfügbar sind, und zeigt +die Bilder ohne diese Bearbeitungen an, bis das Addon wieder aktiviert ist. diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/engrave.md b/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/engrave.md index ad63b2810..b4bafe42d 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/engrave.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/engrave.md @@ -178,13 +178,11 @@ Einstellungen, die unten beschrieben werden. | 10% | 60% | Mittlere Tiefe, sicher | | 20% | 80% | Tiefes, dramatisches Relief | -**Invertieren:** +**Bild invertieren:** -- **Aus** (Standard): Weiß = flach, Schwarz = tief -- **Ein**: Weiß = tief, Schwarz = flach - -Invertieren für Lithophanen verwenden (helle Bereiche sollten dünn sein) oder Prägen (erhabene -Bereiche). +Schwarz wird tief graviert und Weiß bleibt flach. Um sie zu tauschen, etwa für Lithophanen (helle +Bereiche sollten dünn sein) oder Prägen (erhabene Bereiche), wähle das Werkstück aus und verwende +**Tools - Invert Image**. Siehe [Bildbearbeitung](../../addons/image-editing.md). **Helligkeitsbereich:** @@ -280,13 +278,11 @@ ein Raster, das du mit **Halbton-Zellgröße** (Abstand zwischen den Punktmittel - Erzeugt kreuzschraffur-ähnlichen 3D-Effekt - Typisch: 0-45 Grad -**Invertieren:** - -- **Aktiviert:** Weiß = tief, Schwarz = flach -- **Deaktiviert:** Schwarz = tief, Weiß = flach +**Bild invertieren:** -Invertieren für Lithophanen verwenden (helle Bereiche sollten dünn sein) oder Prägen (erhabene -Bereiche). +Schwarz wird tief graviert und Weiß bleibt flach. Um sie zu tauschen, etwa für Lithophanen (helle +Bereiche sollten dünn sein) oder Prägen (erhabene Bereiche), wähle das Werkstück aus und verwende +**Tools - Invert Image**. Siehe [Bildbearbeitung](../../addons/image-editing.md). ### Scan-Muster diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/addons/image-editing.md b/website/i18n/es/docusaurus-plugin-content-docs/current/addons/image-editing.md new file mode 100644 index 000000000..ad8ac619e --- /dev/null +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/addons/image-editing.md @@ -0,0 +1,36 @@ +# Edición de imágenes + +El complemento de edición de imágenes cambia cómo se ve la imagen de una pieza sin tocar el archivo +importado. Cada edición se guarda con la pieza, de modo que puedes deshacerla, quitarla más tarde o +colocar el mismo archivo varias veces con ediciones diferentes. + +El lienzo, la vista previa 3D y cada operación usan la imagen editada. Lo que ves en el lienzo es lo +que el láser graba. + +## Invertir imagen + +Selecciona una o más piezas y elige **Tools - Invert Image**. Las áreas claras se vuelven oscuras y +las áreas oscuras se vuelven claras; las áreas transparentes permanecen transparentes, por lo que el +fondo sigue sin grabarse. + +![Una foto en escala de grises de montañas y un lago, tal como se importó](/images/image-edit-invert-before.webp) + +![La misma foto después de Invert Image](/images/image-edit-invert-after.webp) + +Elígelo de nuevo para quitar la inversión. Con una selección mixta, las piezas que aún no están +invertidas se invierten, de modo que toda la selección acaba igual. + +Úsalo para litofanías y repujado en una operación de grabado, donde las áreas claras deben grabarse +en profundidad, o para grabar dibujos claros sobre un fondo oscuro. + +## Proyectos antiguos + +Antes de este complemento, Invertir era un ajuste de la operación de grabado. Cuando abres un +proyecto antiguo que lo usaba, Rayforge mueve el ajuste a las piezas de esa capa, para que el +resultado sea el mismo. + +## Cuando el complemento está desactivado + +Un proyecto conserva sus ediciones de imagen cuando el complemento está desactivado o no está +instalado. Rayforge te avisa de que el proyecto usa funciones no disponibles y muestra las imágenes +sin esas ediciones hasta que el complemento vuelva a estar activado. diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/engrave.md b/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/engrave.md index b49ac8d41..ee3ed1cf3 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/engrave.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/engrave.md @@ -178,12 +178,11 @@ descritos a continuación. | 10% | 60% | Profundidad media, seguro | | 20% | 80% | Profundo, relieve dramático | -**Invertir:** +**Invertir la imagen:** -- **Apagado** (predeterminado): Blanco = superficial, Negro = profundo -- **Encendido**: Blanco = profundo, Negro = superficial - -Usa invertir para litofanías (las áreas claras deben ser delgadas) o repujado (áreas elevadas). +El negro se graba en profundidad y el blanco queda superficial. Para invertirlos, para litofanías +(las áreas claras deben ser delgadas) o repujado (áreas elevadas), selecciona la pieza y usa +**Tools - Invert Image**. Consulta [Edición de imágenes](../../addons/image-editing.md). **Rango de Brillo:** @@ -280,12 +279,11 @@ punto láser. - Crea un efecto 3D tipo entramado cruzado - Típico: 0-45 grados -**Invertir:** - -- **Habilitado:** Blanco = profundo, Negro = superficial -- **Deshabilitado:** Negro = profundo, Blanco = superficial +**Invertir la imagen:** -Usa invertir para litofanías (las áreas claras deben ser delgadas) o repujado (áreas elevadas). +El negro se graba en profundidad y el blanco queda superficial. Para invertirlos, para litofanías +(las áreas claras deben ser delgadas) o repujado (áreas elevadas), selecciona la pieza y usa +**Tools - Invert Image**. Consulta [Edición de imágenes](../../addons/image-editing.md). ### Patrón de Escaneo diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/addons/image-editing.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/addons/image-editing.md new file mode 100644 index 000000000..ebeac0132 --- /dev/null +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/addons/image-editing.md @@ -0,0 +1,36 @@ +# Édition d'images + +L'extension Édition d'images change l'apparence de l'image d'une pièce sans toucher au fichier +importé. Chaque modification est enregistrée avec la pièce, ce qui permet de l'annuler, de la +supprimer plus tard ou de placer plusieurs fois le même fichier avec des modifications différentes. + +Le canevas, l'aperçu 3D et chaque opération utilisent l'image modifiée. Ce que vous voyez sur le +canevas est ce que le laser grave. + +## Inverser l'image + +Sélectionnez une ou plusieurs pièces et choisissez **Tools - Invert Image**. Les zones claires +deviennent sombres et les zones sombres deviennent claires ; les zones transparentes restent +transparentes, l'arrière-plan n'est donc toujours pas gravé. + +![Une photo en niveaux de gris de montagnes et d'un lac, telle qu'importée](/images/image-edit-invert-before.webp) + +![La même photo après Invert Image](/images/image-edit-invert-after.webp) + +Choisissez-le à nouveau pour supprimer l'inversion. Avec une sélection mixte, les pièces qui ne sont +pas encore inversées le sont, pour que toute la sélection finisse identique. + +Utilisez-le pour les lithophanes et le gaufrage dans une opération de gravure, lorsque les zones +claires doivent être gravées en profondeur, ou pour graver un motif clair sur un fond sombre. + +## Projets plus anciens + +Avant cette extension, Inverser était un réglage de l'opération de gravure. Lorsque vous ouvrez un +ancien projet qui l'utilisait, Rayforge déplace le réglage vers les pièces de cette couche, afin que +le résultat reste le même. + +## Lorsque l'extension est désactivée + +Un projet conserve ses modifications d'image lorsque l'extension est désactivée ou non installée. +Rayforge vous signale que le projet utilise des fonctionnalités non disponibles et affiche les +images sans ces modifications jusqu'à ce que l'extension soit réactivée. diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/engrave.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/engrave.md index ededc1e5a..e8652f36d 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/engrave.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/engrave.md @@ -178,13 +178,11 @@ paramètres, décrits ci-dessous. | 10% | 60% | Profondeur moyenne, sûr | | 20% | 80% | Profond, relief dramatique | -**Inverser :** +**Inversion de l'image :** -- **Off** (défaut) : Blanc = peu profond, Noir = profond -- **On** : Blanc = profond, Noir = peu profond - -Utilisez inverser pour les lithophanes (les zones claires doivent être fines) ou le gaufrage (zones -en relief). +Le noir est gravé en profondeur et le blanc reste peu profond. Pour les inverser, pour les +lithophanes (les zones claires doivent être fines) ou le gaufrage (zones en relief), sélectionnez la +pièce et utilisez **Tools - Invert Image**. Voir [Édition d'images](../../addons/image-editing.md). **Plage de Luminosité :** @@ -281,13 +279,11 @@ de point laser. - Crée un effet 3D type croisé - Typique : 0-45 degrés -**Inverser :** - -- **Activé :** Blanc = profond, Noir = peu profond -- **Désactivé :** Noir = profond, Blanc = peu profond +**Inversion de l'image :** -Utilisez inverser pour les lithophanes (les zones claires doivent être fines) ou le gaufrage (zones -en relief). +Le noir est gravé en profondeur et le blanc reste peu profond. Pour les inverser, pour les +lithophanes (les zones claires doivent être fines) ou le gaufrage (zones en relief), sélectionnez la +pièce et utilisez **Tools - Invert Image**. Voir [Édition d'images](../../addons/image-editing.md). ### Motif de Balayage diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/addons/image-editing.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/addons/image-editing.md new file mode 100644 index 000000000..2371a635f --- /dev/null +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/addons/image-editing.md @@ -0,0 +1,35 @@ +# छवि संपादन + +छवि संपादन ऐड-ऑन आयातित फ़ाइल को छुए बिना वर्कपीस की छवि का स्वरूप बदलता है. प्रत्येक संपादन वर्कपीस +के साथ सहेजा जाता है, ताकि आप इसे पूर्ववत कर सकें, बाद में हटा सकें, या उसी फ़ाइल को अलग-अलग +संपादनों के साथ कई बार रख सकें. + +कार्यक्षेत्र, 3D पूर्वावलोकन और हर ऑपरेशन संपादित छवि का उपयोग करते हैं. जो आप कार्यक्षेत्र पर देखते +हैं, वही लेज़र उकेरता है. + +## छवि उलटें + +एक या अधिक वर्कपीस चुनें और **Tools - Invert Image** चुनें. उजले क्षेत्र गहरे हो जाते हैं और गहरे +क्षेत्र उजले हो जाते हैं; पारदर्शी क्षेत्र पारदर्शी रहते हैं, इसलिए पृष्ठभूमि अभी भी नहीं उकेरी +जाती. + +![आयात की गई, पहाड़ों और झील वाली ग्रेस्केल तस्वीर](/images/image-edit-invert-before.webp) + +![Invert Image के बाद वही तस्वीर](/images/image-edit-invert-after.webp) + +उलटना हटाने के लिए इसे फिर से चुनें. मिश्रित चयन के साथ, जो वर्कपीस अभी तक नहीं उलटे गए हैं वे उलट +दिए जाते हैं, ताकि पूरा चयन अंततः समान हो जाए. + +इसे उत्कीर्णन ऑपरेशन में लिथोफ़ेन और उभार के लिए उपयोग करें, जहाँ उजले क्षेत्र गहरे उकेरे जाने +चाहिए, या गहरी पृष्ठभूमि पर उजली आकृति उकेरने के लिए. + +## पुराने प्रोजेक्ट + +इस ऐड-ऑन से पहले, उलटें उत्कीर्णन ऑपरेशन की एक सेटिंग थी. जब आप कोई पुराना प्रोजेक्ट खोलते हैं जिसने +इसका उपयोग किया था, तो Rayforge उस सेटिंग को उस लेयर के वर्कपीस पर ले जाता है, ताकि परिणाम वही रहे. + +## जब ऐड-ऑन अक्षम हो + +ऐड-ऑन के अक्षम या इंस्टॉल न होने पर प्रोजेक्ट अपने छवि संपादन बनाए रखता है. Rayforge आपको बताता है +कि प्रोजेक्ट अनुपलब्ध फ़ीचर का उपयोग करता है और ऐड-ऑन के फिर से सक्षम होने तक उन संपादनों के बिना +छवियाँ दिखाता है. diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/engrave.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/engrave.md index 630eb0469..ea7c17bc1 100644 --- a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/engrave.md +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/engrave.md @@ -183,12 +183,11 @@ _Step Settings_ टैब के **उत्कीर्णन** और **शक | 10% | 60% | मध्यम गहराई, सुरक्षित | | 20% | 80% | गहरा, नाटकीय रिलीफ़ | -**उलटें:** +**छवि उलटें:** -- **बंद** (डिफ़ॉल्ट): सफ़ेद = उथला, काला = गहरा -- **चालू**: सफ़ेद = गहरा, काला = उथला - -लिथोफ़ेन (उजले क्षेत्र पतले होने चाहिए) या उभार (उठे हुए क्षेत्र) के लिए उलटें उपयोग करें. +काला गहरा उकेरा जाता है और सफ़ेद उथला रहता है. उन्हें बदलने के लिए, लिथोफ़ेन (उजले क्षेत्र पतले होने +चाहिए) या उभार (उठे हुए क्षेत्र) के लिए, वर्कपीस चुनें और **Tools - Invert Image** का उपयोग करें. +[छवि संपादन](../../addons/image-editing.md) देखें. **चमक सीमा:** @@ -281,12 +280,11 @@ _Step Settings_ टैब के **उत्कीर्णन** और **शक - क्रॉसहैच जैसा 3D प्रभाव बनाता है - सामान्य: 0-45 डिग्री -**उलटें:** - -- **सक्षम:** सफ़ेद = गहरा, काला = उथला -- **अक्षम:** काला = गहरा, सफ़ेद = उथला +**छवि उलटें:** -लिथोफ़ेन (उजले क्षेत्र पतले होने चाहिए) या उभार (उठे हुए क्षेत्र) के लिए उलटें उपयोग करें. +काला गहरा उकेरा जाता है और सफ़ेद उथला रहता है. उन्हें बदलने के लिए, लिथोफ़ेन (उजले क्षेत्र पतले होने +चाहिए) या उभार (उठे हुए क्षेत्र) के लिए, वर्कपीस चुनें और **Tools - Invert Image** का उपयोग करें. +[छवि संपादन](../../addons/image-editing.md) देखें. ### स्कैन पैटर्न diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/addons/image-editing.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/addons/image-editing.md new file mode 100644 index 000000000..98382cf0c --- /dev/null +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/addons/image-editing.md @@ -0,0 +1,36 @@ +# Edição de imagens + +O addon de edição de imagens altera como a imagem de uma peça fica sem tocar no arquivo importado. +Cada edição é armazenada junto com a peça, de modo que você pode desfazê-la, removê-la depois ou +colocar o mesmo arquivo várias vezes com edições diferentes. + +A tela, a visualização 3D e cada operação usam a imagem editada. O que você vê na tela é o que o +laser grava. + +## Inverter imagem + +Selecione uma ou mais peças e escolha **Tools - Invert Image**. Áreas claras ficam escuras e áreas +escuras ficam claras; áreas transparentes permanecem transparentes, então o fundo continua sem ser +gravado. + +![Uma foto em tons de cinza de montanhas e um lago, como importada](/images/image-edit-invert-before.webp) + +![A mesma foto depois de Invert Image](/images/image-edit-invert-after.webp) + +Escolha novamente para remover a inversão. Com uma seleção mista, as peças que ainda não estão +invertidas são invertidas, de modo que toda a seleção termine igual. + +Use para litofanias e embossing em uma operação de gravação, onde áreas claras devem ser gravadas em +profundidade, ou para gravar arte clara em um fundo escuro. + +## Projetos mais antigos + +Antes deste addon, Inverter era uma configuração da operação de gravação. Quando você abre um +projeto mais antigo que a usava, o Rayforge move a configuração para as peças dessa camada, para que +o resultado continue o mesmo. + +## Quando o addon está desativado + +Um projeto mantém suas edições de imagem quando o addon está desativado ou não está instalado. O +Rayforge informa que o projeto usa recursos indisponíveis e mostra as imagens sem essas edições até +que o addon seja ativado novamente. diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/engrave.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/engrave.md index ba999f39c..2f380dced 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/engrave.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/engrave.md @@ -178,12 +178,11 @@ diferentes, descritas abaixo. | 10% | 60% | Profundidade média, seguro | | 20% | 80% | Relevo profundo, dramático | -**Inverter:** +**Invertendo a imagem:** -- **Desligado** (padrão): Branco = raso, Preto = profundo -- **Ligado**: Branco = profundo, Preto = raso - -Use inverter para litofanias (áreas claras devem ser finas) ou embossing (áreas elevadas). +O preto é gravado em profundidade e o branco fica raso. Para inverter, para litofanias (áreas claras +devem ser finas) ou embossing (áreas elevadas), selecione a peça e use **Tools - Invert Image**. +Consulte [Edição de imagens](../../addons/image-editing.md). **Faixa de Brilho:** @@ -279,12 +278,11 @@ e **Ângulo do meio-tom**; use um tamanho de célula de pelo menos algumas largu - Cria efeito 3D estilo malha cruzada - Típico: 0-45 graus -**Inverter:** - -- **Habilitado:** Branco = profundo, Preto = raso -- **Desabilitado:** Preto = profundo, Branco = raso +**Invertendo a imagem:** -Use inverter para litofanias (áreas claras devem ser finas) ou embossing (áreas elevadas). +O preto é gravado em profundidade e o branco fica raso. Para inverter, para litofanias (áreas claras +devem ser finas) ou embossing (áreas elevadas), selecione a peça e use **Tools - Invert Image**. +Consulte [Edição de imagens](../../addons/image-editing.md). ### Padrão de Varredura diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/addons/image-editing.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/addons/image-editing.md new file mode 100644 index 000000000..5edefeebc --- /dev/null +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/addons/image-editing.md @@ -0,0 +1,36 @@ +# Редагування зображень + +Розширення редагування зображень змінює вигляд зображення робочого елемента, не торкаючись +імпортованого файлу. Кожне редагування зберігається разом із робочим елементом, тож його можна +скасувати, прибрати пізніше або розмістити той самий файл кілька разів з різними редагуваннями. + +Полотно, 3D-перегляд і кожна операція використовують відредаговане зображення. Те, що ви бачите на +полотні, те й гравіює лазер. + +## Інверсія зображення + +Виділіть один або кілька робочих елементів і виберіть **Tools - Invert Image**. Світлі області +стають темними, а темні — світлими; прозорі області залишаються прозорими, тому тло, як і раніше, не +гравіюється. + +![Чорно-біле фото гір та озера, як імпортовано](/images/image-edit-invert-before.webp) + +![Те саме фото після Invert Image](/images/image-edit-invert-after.webp) + +Виберіть цей пункт ще раз, щоб прибрати інверсію. Для змішаного виділення інвертуються ті робочі +елементи, які ще не інвертовані, тож усе виділення зрештою стає однаковим. + +Використовуйте це для літофаній і тиснення в операції гравіювання, де світлі області мають +гравіюватися глибоко, або для гравіювання світлого зображення на темному тлі. + +## Старіші проєкти + +До цього розширення інверсія була налаштуванням операції гравіювання. Коли ви відкриваєте старіший +проєкт, який його використовував, Rayforge переносить налаштування на робочі елементи цього шару, +тож результат залишається тим самим. + +## Коли розширення вимкнено + +Проєкт зберігає свої редагування зображень, коли розширення вимкнено або не встановлено. Rayforge +повідомляє, що проєкт використовує недоступні функції, і показує зображення без цих редагувань, доки +розширення не буде знову увімкнено. diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/engrave.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/engrave.md index 43dde47da..e3e1a1620 100644 --- a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/engrave.md +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/engrave.md @@ -178,13 +178,11 @@ | 10% | 60% | Середня глибина, безпечно | | 20% | 80% | Глибокий, драматичний рельєф | -**Інверсія:** +**Інверсія зображення:** -- **Вимкнено** (за замовчуванням): Білий = мілко, Чорний = глибоко -- **Увімкнено:** Білий = глибоко, Чорний = мілко - -Використовуйте інверсію для літофаній (світлі області мають бути тонкими) або тиснення (підняті -області). +Чорне гравіюється глибоко, а біле залишається мілким. Щоб поміняти їх місцями, для літофаній (світлі +області мають бути тонкими) або тиснення (підняті області), виділіть робочий елемент і використайте +**Tools - Invert Image**. Див. [Редагування зображень](../../addons/image-editing.md). **Діапазон яскравості:** @@ -278,13 +276,11 @@ - Створює 3D-ефект, схожий на перехресну штриховку - Типово: 0-45 градусів -**Інверсія:** - -- **Увімкнено:** Білий = глибоко, Чорний = мілко -- **Вимкнено:** Чорний = глибоко, Білий = мілко +**Інверсія зображення:** -Використовуйте інверсію для літофаній (світлі області мають бути тонкими) або тиснення (підняті -області). +Чорне гравіюється глибоко, а біле залишається мілким. Щоб поміняти їх місцями, для літофаній (світлі +області мають бути тонкими) або тиснення (підняті області), виділіть робочий елемент і використайте +**Tools - Invert Image**. Див. [Редагування зображень](../../addons/image-editing.md). ### Патерн сканування diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/addons/image-editing.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/addons/image-editing.md new file mode 100644 index 000000000..0b9423fac --- /dev/null +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/addons/image-editing.md @@ -0,0 +1,26 @@ +# 图像编辑 + +图像编辑插件可以在不改动导入文件的情况下更改工件图像的外观。每次编辑都与工件一起保存,因此您可以撤销它、稍后移除它,或者将同一文件以不同的编辑放置多次。 + +画布、3D 预览和每个操作都使用编辑后的图像。您在画布上看到的就是激光雕刻的内容。 + +## 反转图像 + +选择一个或多个工件,然后选择 **Tools - Invert +Image**。浅色区域变深,深色区域变浅;透明区域保持透明,因此背景仍然不会被雕刻。 + +![导入的灰度山湖照片](/images/image-edit-invert-before.webp) + +![Invert Image 之后的同一张照片](/images/image-edit-invert-after.webp) + +再次选择它即可取消反转。对于混合选择,尚未反转的工件会被反转,使整个选择最终保持一致。 + +在雕刻操作中将其用于石版画和浮雕(浅色区域应雕刻得较深),或者在深色背景上雕刻浅色图案。 + +## 旧项目 + +在此插件出现之前,反转是雕刻操作的一个设置。当您打开使用过该设置的旧项目时,Rayforge 会将该设置移动到该图层的工件上,因此输出保持不变。 + +## 插件被禁用时 + +当插件被禁用或未安装时,项目会保留其图像编辑。Rayforge 会提示项目使用了不可用的功能,并在插件重新启用之前显示未应用这些编辑的图像。 diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/engrave.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/engrave.md index c39e48630..10bb05c26 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/engrave.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/engrave.md @@ -168,12 +168,10 @@ | 10% | 60% | 中等深度、安全 | | 20% | 80% | 深、戏剧性浮雕 | -**反转:** +**反转图像:** -- **关闭**(默认):白色 = 浅,黑色 = 深 -- **开启**:白色 = 深,黑色 = 浅 - -反转用于石版画(浅色区域应较薄)或浮雕(凸起区域)。 +黑色雕刻得深,白色保持浅。要交换两者(例如石版画的浅色区域应较薄,或浮雕的凸起区域),请选择工件并使用 +**Tools - Invert Image**。参见[图像编辑](../../addons/image-editing.md)。 **亮度范围:** @@ -257,12 +255,10 @@ - 创建类似交叉阴影的 3D 效果 - 典型:0-45 度 -**反转:** - -- **启用:** 白色 = 深,黑色 = 浅 -- **禁用:** 黑色 = 深,白色 = 浅 +**反转图像:** -反转用于石版画(浅色区域应较薄)或浮雕(凸起区域)。 +黑色雕刻得深,白色保持浅。要交换两者(例如石版画的浅色区域应较薄,或浮雕的凸起区域),请选择工件并使用 +**Tools - Invert Image**。参见[图像编辑](../../addons/image-editing.md)。 ### 扫描图案 diff --git a/website/static/images/image-edit-invert-after.webp b/website/static/images/image-edit-invert-after.webp new file mode 100644 index 0000000000000000000000000000000000000000..da29caed95c100c5e4d8f1966a52a4b05367ec0b GIT binary patch literal 112914 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

+jP&SWRXJtVf-KkdST|Ov|bA&gGa=<2-?)1I0 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�k1%}T$YPeQ2U9}+ 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 OGlaRPtIV+U)-(fRV2)`3 literal 0 HcmV?d00001 diff --git a/website/static/images/image-edit-invert-before.webp b/website/static/images/image-edit-invert-before.webp new file mode 100644 index 0000000000000000000000000000000000000000..62c97248d97aa3180a7c90fd36f39c411a0a64ab GIT binary patch literal 112898 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%0A837o`&HE>o*W9^06OSA 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 literal 0 HcmV?d00001 From b19df26a5f1fc92239977e6e64b6887c90d2c665 Mon Sep 17 00:00:00 2001 From: Samuel Abels Date: Tue, 13 Oct 2026 05:40:37 +0200 Subject: [PATCH 066/104] chore(deps): bump raygeo to 1.64.0 raygeo 1.64.0 ships the node editing operations and the edge_ramp image ramp (raygeo#58, raygeo#57), which the upcoming node edit and engrave ramp features build on. --- debian/requirements-bundle.txt | 2 +- pixi.lock | 90 +++++++++++++++++----------------- pixi.toml | 2 +- requirements.txt | 2 +- 4 files changed, 48 insertions(+), 48 deletions(-) diff --git a/debian/requirements-bundle.txt b/debian/requirements-bundle.txt index 8a9002e3c..382501926 100644 --- a/debian/requirements-bundle.txt +++ b/debian/requirements-bundle.txt @@ -1,7 +1,7 @@ asyncudp==0.11.0 pyvips==3.2.0 raydriver==0.3.0 -raygeo==1.63.0 +raygeo==1.64.0 ruida-pa==0.22.0 trimesh==5.1.1 vtracer==0.6.15 \ No newline at end of file diff --git a/pixi.lock b/pixi.lock index a7d6227f0..797aa3f7b 100644 --- a/pixi.lock +++ b/pixi.lock @@ -243,7 +243,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/13/d4/1152d1c7ab42d8b908be64fd200ddc870dc9d4925e951198702084aa1a7d/linkify_it_py-2.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/17/fe/77f4a24264e728fd95542e53f026a91249b51d1611087b8c82d3a033ea97/asyncudp-0.11.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/18/42/a945f65cc61c739ec80f4112c4b78ed1791f25d33f45f19389c9c9e247e2/pypdf-6.20.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/22/35/285ce45186a60572472c96df5646989ca97710e060419e5c749226a057cf/raygeo-1.63.0.tar.gz + - pypi: https://files.pythonhosted.org/packages/34/cf/ed6a25ccfc7808be6bd36513ba1f66c7a67bd80de44d30847e327fb80971/raygeo-1.64.0.tar.gz - pypi: https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3f/99/2e119d541d596daea39643eb9312b47c7847383951300f889166938035b1/rpyc-6.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/41/51/8041c2e70649e5b7f2a0aedbbbd0609ac099cfaa0cbde2014279c9c05756/cairosvg-2.9.1-py3-none-any.whl @@ -490,6 +490,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/77/c6/040cbc72480d789a5f40d63fb484d3106554c4dfa2d2b70ad5022057750f/webencodings-0.6.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/85/11/044d1ae1b4ec0d7af88ee5bc91e081be1022533032b906a7bdabdbb60977/pyproject_hooks-1.3.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/8b/0a/2368a687f882e13ed5e94ab39fd7f24cb266b1c099df1c0601662339feb7/raygeo-1.64.0-cp311-abi3-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/8d/15/1633010b26e88e872c93b67c0b6c5e174fb74cb6fb5c1472b4d51d4a8f22/platformdirs-4.13.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8f/f6/fb68b8c08f864ecee8f7f7ee2f1203ed5cba2335215de69e4f621eb47db5/trimesh-5.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/90/11/0e6f11117525ff0eec40ebac3d313376f102df93ca44ad9e893ee85e4f89/pycparser-3.11-py3-none-any.whl @@ -524,7 +525,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/fa/48/f36d233ce749d1bb880cb5e6f8bb171dc2effabc7435c31da604d831b3ec/gitpython-3.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/fc/4b/7cf8a68f8865e04e036a5b6847276f97075157ca2360048ba7fd60cd3b88/raygeo-1.63.0-cp311-abi3-macosx_11_0_arm64.whl build: channels: - url: https://conda.anaconda.org/conda-forge/ @@ -918,9 +918,9 @@ environments: - pypi: https://files.pythonhosted.org/packages/13/d4/1152d1c7ab42d8b908be64fd200ddc870dc9d4925e951198702084aa1a7d/linkify_it_py-2.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/17/fe/77f4a24264e728fd95542e53f026a91249b51d1611087b8c82d3a033ea97/asyncudp-0.11.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/18/42/a945f65cc61c739ec80f4112c4b78ed1791f25d33f45f19389c9c9e247e2/pypdf-6.20.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/22/35/285ce45186a60572472c96df5646989ca97710e060419e5c749226a057cf/raygeo-1.63.0.tar.gz - pypi: https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2c/3c/4a01195d93420cad1175bedad13a515dc8a56f95a6e39789b92e582682f5/ruff-0.17.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/34/cf/ed6a25ccfc7808be6bd36513ba1f66c7a67bd80de44d30847e327fb80971/raygeo-1.64.0.tar.gz - pypi: https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3f/99/2e119d541d596daea39643eb9312b47c7847383951300f889166938035b1/rpyc-6.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/41/51/8041c2e70649e5b7f2a0aedbbbd0609ac099cfaa0cbde2014279c9c05756/cairosvg-2.9.1-py3-none-any.whl @@ -1200,6 +1200,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/77/c6/040cbc72480d789a5f40d63fb484d3106554c4dfa2d2b70ad5022057750f/webencodings-0.6.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/85/11/044d1ae1b4ec0d7af88ee5bc91e081be1022533032b906a7bdabdbb60977/pyproject_hooks-1.3.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/8b/0a/2368a687f882e13ed5e94ab39fd7f24cb266b1c099df1c0601662339feb7/raygeo-1.64.0-cp311-abi3-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/8c/be/bf49399ad2e788121f595903a56447d4b778d67acbb01ecfc1c6d5cf91f6/pygobject_stubs-2.17.0.tar.gz - pypi: https://files.pythonhosted.org/packages/8d/15/1633010b26e88e872c93b67c0b6c5e174fb74cb6fb5c1472b4d51d4a8f22/platformdirs-4.13.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8f/f6/fb68b8c08f864ecee8f7f7ee2f1203ed5cba2335215de69e4f621eb47db5/trimesh-5.1.1-py3-none-any.whl @@ -1243,7 +1244,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/fa/70/fffc9613501877c0a10a1ef73a165e6ded3e53f6e7502227fef56d973933/identify-2.6.20-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/fc/4b/7cf8a68f8865e04e036a5b6847276f97075157ca2360048ba7fd60cd3b88/raygeo-1.63.0-cp311-abi3-macosx_11_0_arm64.whl video: channels: - url: https://conda.anaconda.org/conda-forge/ @@ -9494,7 +9494,7 @@ packages: name: rayforge requires_dist: - raydriver==0.3.0 - - raygeo==1.63.0 + - raygeo==1.64.0 - aiohttp==3.14.5 - asyncudp==0.11.0 - blinker==1.9.0 @@ -9652,26 +9652,6 @@ packages: - arabic-reshaper ; extra == 'rtl-text' - python-bidi ; extra == 'rtl-text' requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/22/35/285ce45186a60572472c96df5646989ca97710e060419e5c749226a057cf/raygeo-1.63.0.tar.gz - name: raygeo - version: 1.63.0 - sha256: 6a411c28e7aecea81fa7ccb3d34c652ed1ff4bf1fa677a09323fdd00988a77c8 - requires_dist: - - msgpack - - numpy>=1.20.0 - - pycairo ; extra == 'test' - - pytest ; extra == 'test' - - pytest-mock ; extra == 'test' - - streamlit ; extra == 'test' - - streamlit ; extra == 'visual' - - matplotlib ; extra == 'visual' - - matplotlib ; extra == 'docs' - - mdformat ; extra == 'docs' - - mdformat-frontmatter ; extra == 'docs' - - mdformat-tables ; extra == 'docs' - - pillow ; extra == 'docs' - - matplotlib ; extra == 'cli' - requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl name: mccabe version: 0.7.0 @@ -9708,6 +9688,26 @@ packages: requires_dist: - tomli ; python_full_version <= '3.11' and extra == 'toml' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/34/cf/ed6a25ccfc7808be6bd36513ba1f66c7a67bd80de44d30847e327fb80971/raygeo-1.64.0.tar.gz + name: raygeo + version: 1.64.0 + sha256: f5f7f3f4e02c410cbc6cbe4cff76848a8abbe0662fc37af0a31b3a634a14f1ee + requires_dist: + - msgpack + - numpy>=1.20.0 + - pycairo ; extra == 'test' + - pytest ; extra == 'test' + - pytest-mock ; extra == 'test' + - streamlit ; extra == 'test' + - streamlit ; extra == 'visual' + - matplotlib ; extra == 'visual' + - matplotlib ; extra == 'docs' + - mdformat ; extra == 'docs' + - mdformat-frontmatter ; extra == 'docs' + - mdformat-tables ; extra == 'docs' + - pillow ; extra == 'docs' + - matplotlib ; extra == 'cli' + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl name: pyparsing version: 3.3.3 @@ -9913,6 +9913,26 @@ packages: version: 1.3.3 sha256: 5fc53fdac9f7bd63fbcdc868fb5f90b4784d78a53a3d3388cd738b807441a20b requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/8b/0a/2368a687f882e13ed5e94ab39fd7f24cb266b1c099df1c0601662339feb7/raygeo-1.64.0-cp311-abi3-macosx_11_0_arm64.whl + name: raygeo + version: 1.64.0 + sha256: a5d79525221147db112a159a314011ea4eea53b61ae18ae496decedf729ab477 + requires_dist: + - msgpack + - numpy>=1.20.0 + - matplotlib ; extra == 'cli' + - matplotlib ; extra == 'docs' + - mdformat ; extra == 'docs' + - mdformat-frontmatter ; extra == 'docs' + - mdformat-tables ; extra == 'docs' + - pillow ; extra == 'docs' + - pycairo ; extra == 'test' + - pytest ; extra == 'test' + - pytest-mock ; extra == 'test' + - streamlit ; extra == 'test' + - streamlit ; extra == 'visual' + - matplotlib ; extra == 'visual' + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/8c/be/bf49399ad2e788121f595903a56447d4b778d67acbb01ecfc1c6d5cf91f6/pygobject_stubs-2.17.0.tar.gz name: pygobject-stubs version: 2.17.0 @@ -10555,23 +10575,3 @@ packages: - tree-sitter-yaml>=0.6.0 ; python_full_version >= '3.10' and extra == 'syntax' - typing-extensions>=4.4.0,<5.0.0 requires_python: '>=3.9,<4.0' -- pypi: https://files.pythonhosted.org/packages/fc/4b/7cf8a68f8865e04e036a5b6847276f97075157ca2360048ba7fd60cd3b88/raygeo-1.63.0-cp311-abi3-macosx_11_0_arm64.whl - name: raygeo - version: 1.63.0 - sha256: 80cc04f6a7c723c4c1a6b2cf5aee1491fc99a41ad499c7100623cbbe2cd96022 - requires_dist: - - msgpack - - numpy>=1.20.0 - - matplotlib ; extra == 'cli' - - matplotlib ; extra == 'docs' - - mdformat ; extra == 'docs' - - mdformat-frontmatter ; extra == 'docs' - - mdformat-tables ; extra == 'docs' - - pillow ; extra == 'docs' - - pycairo ; extra == 'test' - - pytest ; extra == 'test' - - pytest-mock ; extra == 'test' - - streamlit ; extra == 'test' - - streamlit ; extra == 'visual' - - matplotlib ; extra == 'visual' - requires_python: '>=3.10' diff --git a/pixi.toml b/pixi.toml index a03873ef6..325f44f42 100644 --- a/pixi.toml +++ b/pixi.toml @@ -38,7 +38,7 @@ rust = "==1.99.0" patchelf = "==0.19.2" [feature.app.pypi-dependencies] -raygeo = "==1.63.0" +raygeo = "==1.64.0" raydriver = "==0.3.0" rayforge = { editable = true, path = "." } ruida-pa = "==0.22.0" diff --git a/requirements.txt b/requirements.txt index 896b8d63b..6ed93615a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ raydriver==0.3.0 -raygeo==1.63.0 +raygeo==1.64.0 aiohttp==3.14.5 asyncudp==0.11.0 blinker==1.9.0 From 4418bc52c6f3db5124fcb92f7966cf4939f5b87a Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Tue, 13 Oct 2026 08:17:52 +0200 Subject: [PATCH 067/104] feat(image-edit): give edits the resolution in pixels per mm get_processed_image() tags the render with xres/yres from the workpiece size before running the edit stack, so an edit with a physical size (such as a sharpen radius in mm) gives the same result on the canvas and in the pipeline. The processed cache key includes the size, so resizing reapplies the edits. Refs #515 --- rayforge/core/image_edit/base.py | 6 +++- rayforge/core/workpiece.py | 29 +++++++++++++--- tests/core/test_workpiece_image_edits.py | 42 +++++++++++++++++++++++- 3 files changed, 71 insertions(+), 6 deletions(-) diff --git a/rayforge/core/image_edit/base.py b/rayforge/core/image_edit/base.py index 00bf9d4fe..2b5b5ffe6 100644 --- a/rayforge/core/image_edit/base.py +++ b/rayforge/core/image_edit/base.py @@ -57,4 +57,8 @@ class ImageEditOp(ABC): def apply( cls, image: pyvips.Image, params: Mapping[str, Any] ) -> pyvips.Image: - """Returns the edited image; ``image`` is left untouched.""" + """ + Returns the edited image; ``image`` is left untouched. Its + ``xres``/``yres`` give the resolution in pixels per mm, for + edits with physical sizes. + """ diff --git a/rayforge/core/workpiece.py b/rayforge/core/workpiece.py index e91e162fc..c443d0e5f 100644 --- a/rayforge/core/workpiece.py +++ b/rayforge/core/workpiece.py @@ -47,6 +47,22 @@ CAIRO_MAX_DIMENSION = 16384 +def _with_resolution( + image: pyvips.Image, size_mm: tuple[float, float] +) -> pyvips.Image: + """ + Tags the image with its resolution in pixels per mm, so edits with + physical sizes (like a sharpen radius) do not depend on the render + size. + """ + width_mm, height_mm = size_mm + if width_mm <= 0 or height_mm <= 0: + return image + return image.copy( + xres=image.width / width_mm, yres=image.height / height_mm + ) + + class RenderContext(NamedTuple): """Encapsulates the resources required for rendering.""" @@ -91,9 +107,11 @@ def __init__( self._render_cache: dict[tuple[int, int], pyvips.Image] = {} # The non-destructive image edit stack and the cache of rendered - # images with that stack applied. Key is (width, height). + # images with that stack applied. Key is (width, height, size). self._image_edits: tuple[ImageEditStep, ...] = () - self._processed_cache: dict[tuple[int, int], pyvips.Image] = {} + self._processed_cache: dict[ + tuple[int, int, tuple[float, float]], pyvips.Image + ] = {} # Transient attributes for deserialized instances in subprocesses self._data: bytes | None = None @@ -846,10 +864,13 @@ def get_processed_image( image = self._get_vips_image(width, height) if image is None or not self._image_edits: return image - key = (width, height) + size = self.size + key = (width, height, size) processed = self._processed_cache.get(key) if processed is None: - processed = apply_image_edits(image, self._image_edits) + processed = apply_image_edits( + _with_resolution(image, size), self._image_edits + ) self._processed_cache[key] = processed return processed diff --git a/tests/core/test_workpiece_image_edits.py b/tests/core/test_workpiece_image_edits.py index 58aeaa472..0df99d213 100644 --- a/tests/core/test_workpiece_image_edits.py +++ b/tests/core/test_workpiece_image_edits.py @@ -1,6 +1,6 @@ from collections.abc import Mapping from pathlib import Path -from typing import Any, cast +from typing import Any, ClassVar, cast import pytest import pyvips @@ -136,3 +136,43 @@ def test_old_documents_load_without_edits(workpiece): def test_in_world_copies_the_edits(workpiece): workpiece.image_edits = [ImageEditStep("test_blank")] assert workpiece.in_world().image_edits == workpiece.image_edits + + +class _ResolutionOp(ImageEditOp): + NAME = "test_resolution" + LABEL = "Resolution" + seen: ClassVar[list[tuple[float, float]]] = [] + + @classmethod + def apply( + cls, image: pyvips.Image, params: Mapping[str, Any] + ) -> pyvips.Image: + cls.seen.append((image.xres, image.yres)) + return image + + +@pytest.fixture +def resolution_op(): + image_edit_registry.register(_ResolutionOp, addon_name="test_wp_res") + _ResolutionOp.seen = [] + yield _ResolutionOp + image_edit_registry.unregister_all_from_addon("test_wp_res") + + +def test_edits_see_the_resolution_in_pixels_per_mm(workpiece, resolution_op): + workpiece.set_size(10.0, 5.0) + workpiece.image_edits = [ImageEditStep(resolution_op.NAME)] + workpiece.get_processed_image(40, 10) + assert resolution_op.seen == [pytest.approx((4.0, 2.0))] + + +def test_resizing_reapplies_the_edits(workpiece, resolution_op): + workpiece.image_edits = [ImageEditStep(resolution_op.NAME)] + workpiece.set_size(10.0, 10.0) + workpiece.get_processed_image(20, 20) + workpiece.set_size(20.0, 20.0) + workpiece.get_processed_image(20, 20) + assert resolution_op.seen == [ + pytest.approx((2.0, 2.0)), + pytest.approx((1.0, 1.0)), + ] From 0aa3b64f113df548e0ba2a13a9d1e9d793c4ec0b Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Tue, 13 Oct 2026 08:17:53 +0200 Subject: [PATCH 068/104] feat(image-edit): add tone and sharpen edits with an Adjust Image dialog Brightness, contrast and gamma become the "tone" edit (one lookup table) and the unsharp mask the "sharpen" edit, with its radius in mm. Both run on the workpiece's image via the image edit registry, in pyvips, so the Engrave step and the pipeline need no knowledge of them. Sharpening blurs premultiplied colour, so transparent areas do not darken the edges. Tools > Adjust Image... edits both for the selected workpieces with a live canvas preview; Apply is one undo step and closing discards. Refs #515 --- .../image_editing/adjust_dialog.py | 170 ++++++++++++++++++ .../image_editing/adjustments.py | 74 ++++++++ .../image_editing/commands.py | 57 +++++- .../image_editing/frontend.py | 30 ++++ .../image_editing/ops.py | 123 ++++++++++++- .../tests/conftest.py | 5 +- .../tests/test_adjustments.py | 45 +++++ .../tests/test_commands.py | 63 ++++++- .../tests/test_ops.py | 129 ++++++++++++- .../tests/test_raster_equivalence.py | 20 ++- .../tests/ui_gtk/conftest.py | 29 +++ .../tests/ui_gtk/test_adjust_dialog.py | 82 +++++++++ 12 files changed, 819 insertions(+), 8 deletions(-) create mode 100644 rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/adjust_dialog.py create mode 100644 rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/adjustments.py create mode 100644 rayforge/builtin_addons/rayforge-addon-image-editing/tests/test_adjustments.py create mode 100644 rayforge/builtin_addons/rayforge-addon-image-editing/tests/ui_gtk/conftest.py create mode 100644 rayforge/builtin_addons/rayforge-addon-image-editing/tests/ui_gtk/test_adjust_dialog.py diff --git a/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/adjust_dialog.py b/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/adjust_dialog.py new file mode 100644 index 000000000..eb4e13d73 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/adjust_dialog.py @@ -0,0 +1,170 @@ +"""The Adjust Image dialog: tone and sharpness of the selected images.""" + +from collections.abc import Sequence +from gettext import gettext as _ + +from gi.repository import Adw, Gtk + +from rayforge.core.undo import HistoryManager +from rayforge.core.workpiece import WorkPiece +from rayforge.ui_gtk.shared.patched_dialog_window import ( + PatchedDialogWindow, +) +from rayforge.ui_gtk.shared.pref_rows import LengthSpinRow, SpinRow + +from .adjustments import ImageAdjustments +from .commands import replace_op_edits, set_op_edits + +#: Delay before an edited value updates the canvas preview. +PREVIEW_DELAY_MS = 250 + + +class AdjustImageDialog(PatchedDialogWindow): + """ + Edits the "sharpen" and "tone" edits of the selected workpieces. + Changes show on the canvas right away; Apply makes them one undo + step and closing the dialog without applying restores the images. + """ + + def __init__( + self, + parent: Gtk.Window | None, + history: HistoryManager, + workpieces: Sequence[WorkPiece], + ): + super().__init__(transient_for=parent) + self._history = history + self._workpieces = list(workpieces) + self._originals = {wp.uid: wp.image_edits for wp in self._workpieces} + self._applied = False + + self.set_title(_("Adjust Image")) + self.set_default_size(440, -1) + self.set_modal(True) + + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + box.append(self._build_header()) + box.append(self._build_page()) + self.set_content(box) + + first = self._workpieces[0].image_edits if self._workpieces else () + self.set_adjustments(ImageAdjustments.from_edits(first)) + self.connect("close-request", self._on_close_request) + + def _build_header(self) -> Adw.HeaderBar: + header = Adw.HeaderBar() + reset_btn = Gtk.Button(label=_("_Reset"), use_underline=True) + reset_btn.connect("clicked", lambda *a: self.reset()) + header.pack_start(reset_btn) + apply_btn = Gtk.Button(label=_("_Apply"), use_underline=True) + apply_btn.add_css_class("suggested-action") + apply_btn.connect("clicked", lambda *a: self.apply()) + header.pack_end(apply_btn) + return header + + def _spin_row(self, title, subtitle, lower, upper, step, digits): + row = SpinRow( + title, + subtitle, + lower=lower, + upper=upper, + step_increment=step, + digits=digits, + numeric=True, + debounce_ms=PREVIEW_DELAY_MS, + ) + row.value_changed.connect(lambda *a: self.preview()) + return row + + def _build_page(self) -> Adw.PreferencesPage: + self.brightness_row = self._spin_row( + _("Brightness"), _("Lighten or darken the image"), -100, 100, 1, 0 + ) + self.contrast_row = self._spin_row( + _("Contrast"), + _("Spread or compress the tones around mid-gray"), + -100, + 100, + 1, + 0, + ) + self.gamma_row = self._spin_row( + _("Gamma"), + _("Values above 1 lighten the midtones, below 1 darken them"), + 0.2, + 5.0, + 0.05, + 2, + ) + self.sharpen_row = self._spin_row( + _("Sharpen"), + _("Strength of the unsharp mask, in percent"), + 0, + 300, + 5, + 0, + ) + self.radius_row = LengthSpinRow( + _("Sharpen Radius"), + _("Size of the details that get sharpened"), + lower=0.01, + upper=5.0, + step_increment=0.05, + digits=3, + debounce_ms=PREVIEW_DELAY_MS, + ) + self.radius_row.value_changed.connect(lambda *a: self.preview()) + + tone = Adw.PreferencesGroup(title=_("Tone")) + for row in (self.brightness_row, self.contrast_row, self.gamma_row): + tone.add(row) + sharpness = Adw.PreferencesGroup(title=_("Sharpness")) + sharpness.add(self.sharpen_row) + sharpness.add(self.radius_row) + page = Adw.PreferencesPage() + page.add(tone) + page.add(sharpness) + return page + + def get_adjustments(self) -> ImageAdjustments: + return ImageAdjustments( + brightness=self.brightness_row.get_value(), + contrast=self.contrast_row.get_value(), + gamma=self.gamma_row.get_value(), + sharpen_amount=self.sharpen_row.get_value(), + sharpen_radius_mm=self.radius_row.get_value_in_base_units(), + ) + + def set_adjustments(self, values: ImageAdjustments) -> None: + self.brightness_row.set_value(values.brightness) + self.contrast_row.set_value(values.contrast) + self.gamma_row.set_value(values.gamma) + self.sharpen_row.set_value(values.sharpen_amount) + self.radius_row.set_value_in_base_units(values.sharpen_radius_mm) + + def preview(self) -> None: + """Shows the current values on the canvas, without undo.""" + edits = self.get_adjustments().to_edits() + for wp in self._workpieces: + wp.image_edits = replace_op_edits(self._originals[wp.uid], edits) + + def reset(self) -> None: + self.set_adjustments(ImageAdjustments()) + self.preview() + + def apply(self) -> None: + set_op_edits( + self._history, + self._workpieces, + self.get_adjustments().to_edits(), + _("Adjust Image"), + self._originals, + ) + self._applied = True + self.close() + + def _on_close_request(self, *args) -> bool: + if not self._applied: + for wp in self._workpieces: + wp.image_edits = self._originals[wp.uid] + return False diff --git a/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/adjustments.py b/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/adjustments.py new file mode 100644 index 000000000..b081b1ad3 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/adjustments.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass + +from rayforge.core.image_edit import ImageEditStep + +from .ops import SharpenOp, ToneOp + + +@dataclass(frozen=True) +class ImageAdjustments: + """ + The values of the Adjust Image dialog. They map to a "sharpen" and a + "tone" edit; neutral values mean the edit is not needed. + """ + + brightness: float = 0.0 + contrast: float = 0.0 + gamma: float = 1.0 + sharpen_amount: float = 0.0 + sharpen_radius_mm: float = 0.2 + + @property + def has_tone(self) -> bool: + return ( + self.brightness != 0.0 or self.contrast != 0.0 or self.gamma != 1.0 + ) + + @property + def has_sharpen(self) -> bool: + return self.sharpen_amount > 0.0 and self.sharpen_radius_mm > 0.0 + + def to_edits(self) -> dict[str, ImageEditStep | None]: + """ + The edits by op name, sharpen first so the tone curve works on + the sharpened image; ``None`` removes that op's edit. + """ + sharpen = ImageEditStep( + SharpenOp.NAME, + { + "amount": self.sharpen_amount, + "radius_mm": self.sharpen_radius_mm, + }, + ) + tone = ImageEditStep( + ToneOp.NAME, + { + "brightness": self.brightness, + "contrast": self.contrast, + "gamma": self.gamma, + }, + ) + return { + SharpenOp.NAME: sharpen if self.has_sharpen else None, + ToneOp.NAME: tone if self.has_tone else None, + } + + @classmethod + def from_edits(cls, edits: Iterable[ImageEditStep]) -> ImageAdjustments: + """Reads the values back from a workpiece's edit stack.""" + defaults = cls() + values = {} + for edit in edits: + if edit.op == ToneOp.NAME: + values["brightness"] = edit.params.get("brightness", 0.0) + values["contrast"] = edit.params.get("contrast", 0.0) + values["gamma"] = edit.params.get("gamma", 1.0) + elif edit.op == SharpenOp.NAME: + values["sharpen_amount"] = edit.params.get("amount", 0.0) + values["sharpen_radius_mm"] = edit.params.get( + "radius_mm", defaults.sharpen_radius_mm + ) + return cls(**values) diff --git a/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/commands.py b/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/commands.py index cc44fdc7c..444afad4e 100644 --- a/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/commands.py +++ b/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/commands.py @@ -1,4 +1,4 @@ -from collections.abc import Sequence +from collections.abc import Mapping, Sequence from rayforge.core.image_edit import ImageEditStep from rayforge.core.undo import ChangePropertyCommand, HistoryManager @@ -42,3 +42,58 @@ def toggle_image_edit( ) ) return add + + +def replace_op_edits( + edits: tuple[ImageEditStep, ...], + replacements: Mapping[str, ImageEditStep | None], +) -> tuple[ImageEditStep, ...]: + """ + Puts each replacement where the edit of the same op was, or appends + it; ``None`` removes the op's edit. Other edits keep their order. + """ + result = [] + placed = set() + for edit in edits: + if edit.op not in replacements: + result.append(edit) + continue + new = replacements[edit.op] + if new is not None and edit.op not in placed: + result.append(new) + placed.add(edit.op) + for op, new in replacements.items(): + if new is not None and op not in placed: + result.append(new) + return tuple(result) + + +def set_op_edits( + history: HistoryManager, + workpieces: Sequence[WorkPiece], + replacements: Mapping[str, ImageEditStep | None], + name: str, + old_edits: Mapping[str, tuple[ImageEditStep, ...]] | None = None, +) -> None: + """ + Applies :func:`replace_op_edits` to every workpiece as one undo + step. ``old_edits`` maps workpiece uids to the stacks to undo to, + for callers that already showed the new edits as a live preview. + """ + old_edits = old_edits or {} + with history.transaction(name) as t: + for wp in workpieces: + old = old_edits.get(wp.uid, wp.image_edits) + new = replace_op_edits(old, replacements) + if new == old: + wp.image_edits = old + continue + t.execute( + ChangePropertyCommand( + target=wp, + property_name="image_edits", + new_value=new, + old_value=old, + name=name, + ) + ) diff --git a/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/frontend.py b/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/frontend.py index a1bb9a23c..fa162f1be 100644 --- a/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/frontend.py +++ b/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/frontend.py @@ -13,6 +13,7 @@ from rayforge.core.hooks import hookimpl from rayforge.ui_gtk.action_registry import MenuPlacement +from .adjust_dialog import AdjustImageDialog from .commands import toggle_image_edit from .ops import INVERT @@ -21,6 +22,7 @@ ADDON_NAME = "image_editing" INVERT_ACTION = "invert-image" +ADJUST_ACTION = "adjust-image" def _has_selection(action_manager: "ActionManager") -> bool: @@ -30,6 +32,11 @@ def _has_selection(action_manager: "ActionManager") -> bool: @hookimpl def register_actions(action_registry): """Register actions with menu placement.""" + _register_invert(action_registry) + _register_adjust(action_registry) + + +def _register_invert(action_registry): action = Gio.SimpleAction.new(INVERT_ACTION, None) def on_activate(action, param): @@ -50,3 +57,26 @@ def on_activate(action, param): menu=MenuPlacement(menu_id="tools", priority=60), enabled_when=_has_selection, ) + + +def _register_adjust(action_registry): + action = Gio.SimpleAction.new(ADJUST_ACTION, None) + + def on_activate(action, param): + window = action_registry.window + dialog = AdjustImageDialog( + window, + window.doc_editor.history_manager, + window.surface.get_selected_workpieces(), + ) + dialog.present() + + action.connect("activate", on_activate) + action_registry.register( + action_name=ADJUST_ACTION, + action=action, + addon_name=ADDON_NAME, + label=_("Adjust Image..."), + menu=MenuPlacement(menu_id="tools", priority=61), + enabled_when=_has_selection, + ) diff --git a/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/ops.py b/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/ops.py index 92dbaaace..97eac4c74 100644 --- a/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/ops.py +++ b/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/ops.py @@ -2,10 +2,33 @@ from gettext import gettext as _ from typing import Any +import numpy as np import pyvips from rayforge.core.image_edit import ImageEditOp, ImageEditStep +#: Smallest blur sigma, in pixels, that still changes the image. +MIN_SIGMA_PX = 0.1 + + +def _max_value(image: pyvips.Image) -> int: + return 65535 if image.format == "ushort" else 255 + + +def _split_alpha( + image: pyvips.Image, +) -> tuple[pyvips.Image, pyvips.Image | None]: + if not image.hasalpha(): + return image, None + color = image.extract_band(0, n=image.bands - 1) + return color, image.extract_band(image.bands - 1) + + +def _join_alpha( + color: pyvips.Image, alpha: pyvips.Image | None +) -> pyvips.Image: + return color if alpha is None else color.bandjoin(alpha) + class InvertOp(ImageEditOp): """Inverts the colour of the image and leaves its alpha alone.""" @@ -24,6 +47,104 @@ def apply( return color.invert().bandjoin(alpha) +def tone_curve( + brightness: float, contrast: float, gamma: float, max_value: int = 255 +) -> np.ndarray: + """ + The tone curve as a lookup table with ``max_value + 1`` entries: + brightness, then contrast around mid-gray, then gamma. Brightness + and contrast run from -100 to 100; a gamma above 1 lightens the + midtones. + """ + values = np.linspace(0.0, 1.0, max_value + 1) + values = values + brightness / 100.0 + factor = max(0.0, 1.0 + contrast / 100.0) + values = np.clip((values - 0.5) * factor + 0.5, 0.0, 1.0) + values = values ** (1.0 / max(gamma, 1e-3)) + dtype = np.uint16 if max_value > 255 else np.uint8 + return np.rint(values * max_value).astype(dtype) + + +class ToneOp(ImageEditOp): + """Brightness, contrast and gamma of the colour; alpha is kept.""" + + NAME = "tone" + LABEL = _("Brightness and Contrast") + + @classmethod + def apply( + cls, image: pyvips.Image, params: Mapping[str, Any] + ) -> pyvips.Image: + color, alpha = _split_alpha(image) + if color.format not in ("uchar", "ushort"): + color = color.cast("uchar") + lut = tone_curve( + params.get("brightness", 0.0), + params.get("contrast", 0.0), + params.get("gamma", 1.0), + _max_value(color), + ) + table = pyvips.Image.new_from_array(lut.reshape(1, -1)) + return _join_alpha(color.maplut(table), alpha) + + +def _blur(image: pyvips.Image, sigma_x: float, sigma_y: float) -> pyvips.Image: + if sigma_x >= MIN_SIGMA_PX: + mask = pyvips.Image.gaussmat( + sigma_x, 0.01, separable=True, precision="float" + ) + image = image.conv(mask, precision="float") + if sigma_y >= MIN_SIGMA_PX: + mask = pyvips.Image.gaussmat( + sigma_y, 0.01, separable=True, precision="float" + ) + image = image.conv(mask.rot90(), precision="float") + return image + + +def _blurred_color( + image: pyvips.Image, sigma_x: float, sigma_y: float +) -> pyvips.Image: + """ + The blurred colour bands. With alpha, the blur runs on premultiplied + colour, so transparent pixels do not darken the edges. + """ + if not image.hasalpha(): + return _blur(image.cast("float"), sigma_x, sigma_y) + max_alpha = _max_value(image) + premultiplied = image.premultiply(max_alpha=max_alpha) + blurred = _blur(premultiplied, sigma_x, sigma_y) + color = blurred.unpremultiply(max_alpha=max_alpha) + return color.extract_band(0, n=image.bands - 1) + + +class SharpenOp(ImageEditOp): + """ + Unsharp mask: ``amount`` is the strength in percent and + ``radius_mm`` the blur radius (Gaussian sigma) in millimetres, + turned into pixels with the image's resolution. + """ + + NAME = "sharpen" + LABEL = _("Sharpen") + + @classmethod + def apply( + cls, image: pyvips.Image, params: Mapping[str, Any] + ) -> pyvips.Image: + amount = params.get("amount", 0.0) / 100.0 + radius = params.get("radius_mm", 0.0) + sigma_x, sigma_y = radius * image.xres, radius * image.yres + if amount <= 0.0 or max(sigma_x, sigma_y) < MIN_SIGMA_PX: + return image + color, alpha = _split_alpha(image) + values = color.cast("float") + blurred = _blurred_color(image, sigma_x, sigma_y) + sharpened = values + (values - blurred) * amount + result = sharpened.rint().cast(color.format) + return _join_alpha(result, alpha) + + INVERT = ImageEditStep(InvertOp.NAME) -ALL_OPS = (InvertOp,) +ALL_OPS = (InvertOp, ToneOp, SharpenOp) diff --git a/rayforge/builtin_addons/rayforge-addon-image-editing/tests/conftest.py b/rayforge/builtin_addons/rayforge-addon-image-editing/tests/conftest.py index 610cc9fd8..b21a0455f 100644 --- a/rayforge/builtin_addons/rayforge-addon-image-editing/tests/conftest.py +++ b/rayforge/builtin_addons/rayforge-addon-image-editing/tests/conftest.py @@ -3,7 +3,7 @@ """ import pytest -from image_editing.ops import InvertOp +from image_editing.ops import ALL_OPS from rayforge.core.image_edit import image_edit_registry @@ -13,6 +13,7 @@ @pytest.fixture(scope="session", autouse=True) def register_image_edits(): """Registers the addon's image edit ops for all tests.""" - image_edit_registry.register(InvertOp, addon_name=ADDON_NAME) + for op_class in ALL_OPS: + image_edit_registry.register(op_class, addon_name=ADDON_NAME) yield image_edit_registry.unregister_all_from_addon(ADDON_NAME) diff --git a/rayforge/builtin_addons/rayforge-addon-image-editing/tests/test_adjustments.py b/rayforge/builtin_addons/rayforge-addon-image-editing/tests/test_adjustments.py new file mode 100644 index 000000000..778a703b7 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-image-editing/tests/test_adjustments.py @@ -0,0 +1,45 @@ +from image_editing.adjustments import ImageAdjustments + +from rayforge.core.image_edit import ImageEditStep + + +def test_defaults_need_no_edits(): + assert ImageAdjustments().to_edits() == {"sharpen": None, "tone": None} + + +def test_tone_values_become_a_tone_edit(): + edits = ImageAdjustments(brightness=10, gamma=1.5).to_edits() + assert edits["sharpen"] is None + assert edits["tone"] == ImageEditStep( + "tone", {"brightness": 10, "contrast": 0.0, "gamma": 1.5} + ) + + +def test_sharpen_needs_amount_and_radius(): + assert ImageAdjustments(sharpen_amount=50).to_edits()["sharpen"] == ( + ImageEditStep("sharpen", {"amount": 50, "radius_mm": 0.2}) + ) + no_radius = ImageAdjustments(sharpen_amount=50, sharpen_radius_mm=0) + assert no_radius.to_edits()["sharpen"] is None + + +def test_sharpen_comes_before_tone(): + edits = ImageAdjustments(contrast=5, sharpen_amount=50).to_edits() + assert list(edits) == ["sharpen", "tone"] + + +def test_round_trip_through_the_edit_stack(): + values = ImageAdjustments( + brightness=-5, + contrast=20, + gamma=0.8, + sharpen_amount=120, + sharpen_radius_mm=0.3, + ) + edits = [e for e in values.to_edits().values() if e is not None] + stack = [ImageEditStep("invert"), *edits] + assert ImageAdjustments.from_edits(stack) == values + + +def test_empty_stack_reads_as_defaults(): + assert ImageAdjustments.from_edits([]) == ImageAdjustments() diff --git a/rayforge/builtin_addons/rayforge-addon-image-editing/tests/test_commands.py b/rayforge/builtin_addons/rayforge-addon-image-editing/tests/test_commands.py index 2a975e189..82f6a6281 100644 --- a/rayforge/builtin_addons/rayforge-addon-image-editing/tests/test_commands.py +++ b/rayforge/builtin_addons/rayforge-addon-image-editing/tests/test_commands.py @@ -1,4 +1,8 @@ -from image_editing.commands import toggle_image_edit +from image_editing.commands import ( + replace_op_edits, + set_op_edits, + toggle_image_edit, +) from image_editing.ops import INVERT from rayforge.core.image_edit import ImageEditStep @@ -49,3 +53,60 @@ def test_empty_selection_does_nothing(): history = HistoryManager() assert not toggle_image_edit(history, [], INVERT, "Invert") assert not history.can_undo() + + +TONE = ImageEditStep("tone", {"brightness": 10}) +NEW_TONE = ImageEditStep("tone", {"brightness": 20}) +SHARPEN = ImageEditStep("sharpen", {"amount": 50, "radius_mm": 0.2}) + + +def test_replace_keeps_the_position_of_the_op(): + edits = (TONE, INVERT) + assert replace_op_edits(edits, {"tone": NEW_TONE}) == (NEW_TONE, INVERT) + + +def test_replace_appends_missing_ops_in_order(): + result = replace_op_edits((INVERT,), {"sharpen": SHARPEN, "tone": TONE}) + assert result == (INVERT, SHARPEN, TONE) + + +def test_replace_with_none_removes_the_op(): + edits = (TONE, INVERT, SHARPEN) + result = replace_op_edits(edits, {"tone": None, "sharpen": None}) + assert result == (INVERT,) + + +def test_replace_drops_duplicate_edits_of_the_op(): + edits = (TONE, INVERT, TONE) + assert replace_op_edits(edits, {"tone": NEW_TONE}) == (NEW_TONE, INVERT) + + +def test_set_op_edits_is_one_undo_step(): + history = HistoryManager() + a, b = WorkPiece("a"), WorkPiece("b") + b.image_edits = [INVERT] + set_op_edits(history, [a, b], {"tone": TONE}, "Adjust") + assert a.image_edits == (TONE,) + assert b.image_edits == (INVERT, TONE) + history.undo() + assert a.image_edits == () + assert b.image_edits == (INVERT,) + + +def test_set_op_edits_undoes_to_the_stack_before_the_preview(): + history = HistoryManager() + wp = WorkPiece("a") + wp.image_edits = [NEW_TONE] + set_op_edits(history, [wp], {"tone": TONE}, "Adjust", {wp.uid: ()}) + assert wp.image_edits == (TONE,) + history.undo() + assert wp.image_edits == () + + +def test_set_op_edits_without_change_restores_the_preview(): + history = HistoryManager() + wp = WorkPiece("a") + wp.image_edits = [NEW_TONE] + set_op_edits(history, [wp], {"tone": None}, "Adjust", {wp.uid: ()}) + assert wp.image_edits == () + assert not history.can_undo() diff --git a/rayforge/builtin_addons/rayforge-addon-image-editing/tests/test_ops.py b/rayforge/builtin_addons/rayforge-addon-image-editing/tests/test_ops.py index 7851b6296..64fa84c6a 100644 --- a/rayforge/builtin_addons/rayforge-addon-image-editing/tests/test_ops.py +++ b/rayforge/builtin_addons/rayforge-addon-image-editing/tests/test_ops.py @@ -1,6 +1,12 @@ import numpy as np import pyvips -from image_editing.ops import INVERT, InvertOp +from image_editing.ops import ( + INVERT, + InvertOp, + SharpenOp, + ToneOp, + tone_curve, +) from rayforge.core.image_edit import image_edit_registry @@ -31,3 +37,124 @@ def test_invert_without_alpha(): def test_invert_grayscale_with_alpha(): gray_alpha = _image(40, 0).copy(interpretation="b-w") assert _pixel(InvertOp.apply(gray_alpha, {})) == [215, 0] + + +def _ramp(width: int = 256, height: int = 4, alpha: int | None = None): + row = np.arange(width, dtype=np.uint8) + pixels = np.tile(row, (height, 1)) + if alpha is None: + return pyvips.Image.new_from_array(pixels).copy(interpretation="b-w") + bands = np.dstack([pixels, np.full_like(pixels, alpha)]) + return pyvips.Image.new_from_array(bands).copy(interpretation="b-w") + + +def _gray(*values: int) -> pyvips.Image: + pixels = np.array([values], dtype=np.uint8) + return pyvips.Image.new_from_array(pixels).copy(interpretation="b-w") + + +def _row(image: pyvips.Image) -> list[int]: + return image.numpy().reshape(image.height, -1)[0].tolist() + + +def _tone(image: pyvips.Image, **params) -> pyvips.Image: + return ToneOp.apply(image, params) + + +def test_tone_and_sharpen_are_registered(): + assert image_edit_registry.get("tone") is ToneOp + assert image_edit_registry.get("sharpen") is SharpenOp + + +def test_tone_curve_is_identity_by_default(): + np.testing.assert_array_equal(tone_curve(0, 0, 1.0), np.arange(256)) + assert tone_curve(0, 0, 1.0, 65535).dtype == np.uint16 + + +def test_neutral_tone_keeps_the_image(): + image = _ramp() + assert _row(_tone(image)) == _row(image) + + +def test_brightness_shifts_and_clips(): + assert _row(_tone(_gray(100), brightness=20)) == [151] + assert _row(_tone(_gray(100), brightness=-20)) == [49] + assert set(_row(_tone(_ramp(), brightness=100))) == {255} + assert set(_row(_tone(_ramp(), brightness=-100))) == {0} + + +def test_contrast_scales_around_mid_gray(): + image = _gray(64, 128, 192) + assert _row(_tone(image, contrast=50)) == [32, 128, 224] + assert _row(_tone(image, contrast=-50)) == [96, 128, 160] + assert set(_row(_tone(_ramp(), contrast=-100))) == {128} + + +def test_gamma_above_one_lightens_midtones(): + image = _gray(0, 128, 255) + lighter = _row(_tone(image, gamma=2.0)) + darker = _row(_tone(image, gamma=0.5)) + assert lighter == [0, round(255 * (128 / 255) ** 0.5), 255] + assert darker == [0, round(255 * (128 / 255) ** 2), 255] + + +def test_tone_curve_is_monotonic(): + curve = tone_curve(10, 30, 1.4).astype(int) + assert (np.diff(curve) >= 0).all() + + +def test_tone_keeps_alpha_and_colour_format(): + rgba = _image(100, 100, 100, 7).copy(interpretation="srgb") + result = _tone(rgba, brightness=20) + assert _pixel(result) == [151, 151, 151, 7] + assert result.format == "uchar" + + +def test_tone_handles_16_bit_images(): + pixels = np.array([[0, 32768, 65535]], dtype=np.uint16) + image = pyvips.Image.new_from_array(pixels).copy(interpretation="grey16") + result = _tone(image, contrast=-100) + assert result.format == "ushort" + assert set(_row(result)) == {32768} + + +def _edge(ppm: float = 10.0) -> pyvips.Image: + pixels = np.zeros((20, 40), dtype=np.uint8) + pixels[:, :20] = 60 + pixels[:, 20:] = 180 + image = pyvips.Image.new_from_array(pixels).copy(interpretation="b-w") + return image.copy(xres=ppm, yres=ppm) + + +def _sharpen(image: pyvips.Image, **params) -> pyvips.Image: + return SharpenOp.apply(image, params) + + +def test_sharpen_without_amount_or_radius_is_neutral(): + image = _edge() + assert _sharpen(image, amount=0, radius_mm=0.2) is image + assert _sharpen(image, amount=100, radius_mm=0.0) is image + + +def test_sharpen_increases_edge_contrast(): + result = _row(_sharpen(_edge(), amount=100, radius_mm=0.2)) + assert result[19] < 60 + assert result[20] > 180 + assert result[0] == 60 and result[-1] == 180 + + +def test_sharpen_radius_is_in_mm(): + coarse = _row(_sharpen(_edge(ppm=10.0), amount=100, radius_mm=0.2)) + fine = _row(_sharpen(_edge(ppm=5.0), amount=100, radius_mm=0.4)) + assert coarse == fine + + +def test_sharpen_ignores_transparent_neighbours(): + pixels = np.zeros((10, 20, 2), dtype=np.uint8) + pixels[:, 10:] = (120, 255) + image = pyvips.Image.new_from_array(pixels).copy( + interpretation="b-w", xres=10.0, yres=10.0 + ) + result = _sharpen(image, amount=200, radius_mm=0.2).numpy() + assert set(result[:, 10:, 0].ravel()) == {120} + assert set(result[..., 1].ravel()) == {0, 255} diff --git a/rayforge/builtin_addons/rayforge-addon-image-editing/tests/test_raster_equivalence.py b/rayforge/builtin_addons/rayforge-addon-image-editing/tests/test_raster_equivalence.py index ce1914470..8df1702a4 100644 --- a/rayforge/builtin_addons/rayforge-addon-image-editing/tests/test_raster_equivalence.py +++ b/rayforge/builtin_addons/rayforge-addon-image-editing/tests/test_raster_equivalence.py @@ -1,11 +1,12 @@ """ The Invert edit replaces the Engrave step's old Invert setting; these -tests pin the behaviour that setting had. +tests pin the behaviour that setting had, and that the other edits reach +the Engrave step the same way. """ import numpy as np import pyvips -from image_editing.ops import InvertOp +from image_editing.ops import InvertOp, ToneOp from rayforge.image.dither import DitherAlgorithm from rayforge.image.util import normalize_to_rgba, vips_rgba_to_cairo_surface @@ -50,3 +51,18 @@ def test_inverted_power_modulation_flips_visible_pixels_only(): assert gray is not None and alpha is not None assert np.all(gray[:, :5] == 0) assert np.all(alpha[:, 5:] == 0) + + +def test_tone_edit_reaches_power_modulation(): + pixels = np.zeros((10, 10, 4), dtype=np.uint8) + pixels[:, :5] = (128, 128, 128, 255) + image = pyvips.Image.new_from_array(pixels).copy(interpretation="srgb") + darker = ToneOp.apply(image, {"gamma": 0.5}) + gray, alpha = preprocess_raster_image( + _surface(darker), + mode=DepthMode.POWER_MODULATION, + auto_levels=False, + ) + assert gray is not None and alpha is not None + assert np.all(gray[:, :5] == round(255 * (128 / 255) ** 2)) + assert np.all(alpha[:, 5:] == 0) diff --git a/rayforge/builtin_addons/rayforge-addon-image-editing/tests/ui_gtk/conftest.py b/rayforge/builtin_addons/rayforge-addon-image-editing/tests/ui_gtk/conftest.py new file mode 100644 index 000000000..0906c6f6c --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-image-editing/tests/ui_gtk/conftest.py @@ -0,0 +1,29 @@ +"""UI fixtures for image_editing dialog tests.""" + +import asyncio + +import pytest + +from rayforge import config as config_module +from rayforge import context as context_module +from rayforge.context import get_context + + +@pytest.fixture +def ui_context(monkeypatch, tmp_path): + """An isolated application context, as the unit rows need one.""" + config_dir = tmp_path / "config" + monkeypatch.setattr(config_module, "CONFIG_DIR", config_dir) + monkeypatch.setattr(config_module, "DIALECT_DIR", config_dir / "dialects") + monkeypatch.setattr(config_module, "MACHINE_DIR", config_dir / "machines") + monkeypatch.setattr(config_module, "ADDONS_DIR", config_dir / "addons") + monkeypatch.setattr( + config_module, "CONFIG_FILE", config_dir / "config.yaml" + ) + monkeypatch.setattr( + config_module, "AI_CONFIG_FILE", config_dir / "ai.yaml" + ) + context = get_context() + yield context + asyncio.run(context.shutdown()) + context_module._context_instance = None diff --git a/rayforge/builtin_addons/rayforge-addon-image-editing/tests/ui_gtk/test_adjust_dialog.py b/rayforge/builtin_addons/rayforge-addon-image-editing/tests/ui_gtk/test_adjust_dialog.py new file mode 100644 index 000000000..56e9cb98b --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-image-editing/tests/ui_gtk/test_adjust_dialog.py @@ -0,0 +1,82 @@ +# flake8: noqa: E402 +"""UI tests for the Adjust Image dialog.""" + +import gi +import pytest + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") + +from image_editing.adjust_dialog import AdjustImageDialog +from image_editing.adjustments import ImageAdjustments +from image_editing.ops import INVERT + +from rayforge.core.image_edit import ImageEditStep +from rayforge.core.undo import HistoryManager +from rayforge.core.workpiece import WorkPiece + +TONE = ImageEditStep( + "tone", {"brightness": 10.0, "contrast": 0.0, "gamma": 1.0} +) + + +@pytest.fixture +def workpieces(): + a, b = WorkPiece("a"), WorkPiece("b") + a.image_edits = [TONE] + b.image_edits = [INVERT] + return [a, b] + + +@pytest.fixture +def dialog(ui_context, workpieces): + dlg = AdjustImageDialog(None, HistoryManager(), workpieces) + dlg.present() + yield dlg + dlg.destroy() + + +@pytest.mark.ui +def test_opens_with_the_first_workpiece_values(dialog): + assert dialog.get_adjustments() == ImageAdjustments(brightness=10) + + +@pytest.mark.ui +def test_preview_shows_without_undo(dialog, workpieces): + dialog.contrast_row.set_value(20) + dialog.preview() + a, b = workpieces + assert a.image_edits[0].params["contrast"] == 20 + assert b.image_edits[0] == INVERT + assert b.image_edits[1].params["contrast"] == 20 + assert not dialog._history.can_undo() + + +@pytest.mark.ui +def test_closing_without_apply_restores_the_edits(dialog, workpieces): + dialog.sharpen_row.set_value(100) + dialog.preview() + dialog.close() + assert workpieces[0].image_edits == (TONE,) + assert workpieces[1].image_edits == (INVERT,) + + +@pytest.mark.ui +def test_apply_is_one_undo_step(dialog, workpieces): + history = dialog._history + dialog.gamma_row.set_value(1.5) + dialog.preview() + dialog.apply() + assert workpieces[0].image_edits[0].params["gamma"] == 1.5 + assert workpieces[1].image_edits[1].params["gamma"] == 1.5 + history.undo() + assert workpieces[0].image_edits == (TONE,) + assert workpieces[1].image_edits == (INVERT,) + + +@pytest.mark.ui +def test_reset_removes_the_adjustments(dialog, workpieces): + dialog.reset() + dialog.apply() + assert workpieces[0].image_edits == () + assert workpieces[1].image_edits == (INVERT,) From 60a6b0dd77d349b8656e5be6b881270c914640b3 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Tue, 13 Oct 2026 08:17:53 +0200 Subject: [PATCH 069/104] docs: document the Adjust Image edit Refs #515 --- CHANGELOG.md | 4 ++++ website/docs/addons/image-editing.md | 20 ++++++++++++++++++ .../current/addons/image-editing.md | 21 +++++++++++++++++++ .../current/addons/image-editing.md | 21 +++++++++++++++++++ .../current/addons/image-editing.md | 21 +++++++++++++++++++ .../current/addons/image-editing.md | 20 ++++++++++++++++++ .../current/addons/image-editing.md | 21 +++++++++++++++++++ .../current/addons/image-editing.md | 20 ++++++++++++++++++ .../current/addons/image-editing.md | 16 ++++++++++++++ 9 files changed, 164 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d16ae179e..32a067751 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Image Editing addon: non-destructive image edits stored per workpiece and applied wherever its image is used (canvas, 3D preview, operations). The first edit is Tools > Invert Image (#515) +- Image Editing: Tools > Adjust Image... corrects the brightness, + contrast and gamma of the selected images and sharpens them with an + unsharp mask whose radius is in mm. The canvas shows the result while + editing, and Apply is a single undo step (#515) - Addons: the `register_image_edits` hook and image edit registry (plugin API 23) let addons provide their own image edits diff --git a/website/docs/addons/image-editing.md b/website/docs/addons/image-editing.md index 586fd267c..cf9353ef9 100644 --- a/website/docs/addons/image-editing.md +++ b/website/docs/addons/image-editing.md @@ -28,6 +28,26 @@ inverted yet get inverted, so the whole selection ends up the same. Use it for lithophanes and embossing in an Engrave operation, where light areas should be engraved deep, or to engrave light artwork on a dark background. +## Adjust Image + +Select one or more workpieces and choose **Tools - Adjust Image...** to correct a photo before +engraving or tracing it: + +- **Brightness** lightens or darkens the image. +- **Contrast** spreads the tones away from mid-gray, or pulls them towards it. +- **Gamma** above 1 lightens the midtones, below 1 darkens them; black and white stay as they are. +- **Sharpen** strengthens edges and fine details (an unsharp mask). **Sharpen Radius** sets the size + of the details it works on, in millimeters, so the result is the same at any line interval or + canvas zoom. + +The canvas shows the changes while you edit. **Apply** keeps them as a single undo step; closing the +dialog without applying discards them, and **Reset** sets every value back to neutral. Transparent +areas stay transparent. + +The dialog opens with the values of the first selected workpiece, and Apply gives every selected +workpiece the same values. Sharpening runs before the tone corrections, and other edits such as +Invert Image keep their place. + ## Older Projects Before this addon, Invert was a setting of the Engrave operation. When you open an older project diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/addons/image-editing.md b/website/i18n/de/docusaurus-plugin-content-docs/current/addons/image-editing.md index 019933c98..2a2515e94 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/addons/image-editing.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/addons/image-editing.md @@ -25,6 +25,27 @@ ist. Verwende es für Lithophanen und Prägen in einer Gravur-Operation, bei der helle Bereiche tief graviert werden sollen, oder um helle Motive auf dunklem Hintergrund zu gravieren. +## Bild anpassen + +Wähle ein oder mehrere Werkstücke aus und wähle **Tools - Adjust Image...**, um ein Foto vor dem +Gravieren oder Nachzeichnen zu korrigieren: + +- **Brightness** (Helligkeit) hellt das Bild auf oder dunkelt es ab. +- **Contrast** (Kontrast) zieht die Tonwerte vom mittleren Grau weg oder zu ihm hin. +- **Gamma** über 1 hellt die Mitteltöne auf, unter 1 dunkelt es sie ab; Schwarz und Weiß bleiben, + wie sie sind. +- **Sharpen** (Schärfen) verstärkt Kanten und feine Details (eine Unscharfmaskierung). **Sharpen + Radius** legt die Größe der Details in Millimetern fest, sodass das Ergebnis bei jedem + Zeilenabstand und jeder Zoomstufe gleich ist. + +Die Leinwand zeigt die Änderungen schon während der Bearbeitung. **Apply** übernimmt sie als einen +einzigen Rückgängig-Schritt; wenn du den Dialog ohne Übernehmen schließt, werden sie verworfen, und +**Reset** setzt alle Werte auf neutral zurück. Transparente Bereiche bleiben transparent. + +Der Dialog öffnet sich mit den Werten des ersten ausgewählten Werkstücks, und Apply gibt jedem +ausgewählten Werkstück dieselben Werte. Das Schärfen erfolgt vor den Tonwertkorrekturen, und andere +Bearbeitungen wie Invert Image behalten ihren Platz. + ## Ältere Projekte Vor diesem Addon war Invertieren eine Einstellung der Gravur-Operation. Wenn du ein älteres Projekt diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/addons/image-editing.md b/website/i18n/es/docusaurus-plugin-content-docs/current/addons/image-editing.md index ad8ac619e..5bfe515f0 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/addons/image-editing.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/addons/image-editing.md @@ -23,6 +23,27 @@ invertidas se invierten, de modo que toda la selección acaba igual. Úsalo para litofanías y repujado en una operación de grabado, donde las áreas claras deben grabarse en profundidad, o para grabar dibujos claros sobre un fondo oscuro. +## Ajustar imagen + +Selecciona una o más piezas de trabajo y elige **Tools - Adjust Image...** para corregir una foto +antes de grabarla o vectorizarla: + +- **Brightness** (brillo) aclara u oscurece la imagen. +- **Contrast** (contraste) aleja los tonos del gris medio o los acerca a él. +- **Gamma** por encima de 1 aclara los tonos medios y por debajo de 1 los oscurece; el negro y el + blanco no cambian. +- **Sharpen** (enfocar) refuerza los bordes y los detalles finos (una máscara de enfoque). **Sharpen + Radius** fija el tamaño de esos detalles en milímetros, de modo que el resultado es el mismo con + cualquier intervalo de línea o nivel de zoom. + +El lienzo muestra los cambios mientras editas. **Apply** los conserva como un único paso de +deshacer; si cierras el diálogo sin aplicar, se descartan, y **Reset** devuelve todos los valores a +neutro. Las zonas transparentes siguen siendo transparentes. + +El diálogo se abre con los valores de la primera pieza seleccionada, y Apply da los mismos valores a +todas las piezas seleccionadas. El enfoque se aplica antes de las correcciones de tono, y otras +ediciones como Invert Image mantienen su lugar. + ## Proyectos antiguos Antes de este complemento, Invertir era un ajuste de la operación de grabado. Cuando abres un diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/addons/image-editing.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/addons/image-editing.md index ebeac0132..c2b8d00e1 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/addons/image-editing.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/addons/image-editing.md @@ -23,6 +23,27 @@ pas encore inversées le sont, pour que toute la sélection finisse identique. Utilisez-le pour les lithophanes et le gaufrage dans une opération de gravure, lorsque les zones claires doivent être gravées en profondeur, ou pour graver un motif clair sur un fond sombre. +## Ajuster l'image + +Sélectionnez une ou plusieurs pièces et choisissez **Tools - Adjust Image...** pour corriger une +photo avant de la graver ou de la vectoriser : + +- **Brightness** (luminosité) éclaircit ou assombrit l'image. +- **Contrast** (contraste) écarte les tons du gris moyen ou les en rapproche. +- **Gamma** au-dessus de 1 éclaircit les tons moyens, en dessous de 1 les assombrit ; le noir et le + blanc ne changent pas. +- **Sharpen** (netteté) renforce les contours et les détails fins (un masque flou). **Sharpen + Radius** fixe la taille de ces détails en millimètres, si bien que le résultat est le même quel + que soit l'intervalle de ligne ou le zoom. + +Le canevas affiche les modifications pendant l'édition. **Apply** les conserve en une seule étape +d'annulation ; fermer la boîte de dialogue sans appliquer les abandonne, et **Reset** remet toutes +les valeurs au neutre. Les zones transparentes restent transparentes. + +La boîte de dialogue s'ouvre avec les valeurs de la première pièce sélectionnée, et Apply donne les +mêmes valeurs à toutes les pièces sélectionnées. La netteté est appliquée avant les corrections de +tons, et les autres modifications comme Invert Image gardent leur place. + ## Projets plus anciens Avant cette extension, Inverser était un réglage de l'opération de gravure. Lorsque vous ouvrez un diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/addons/image-editing.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/addons/image-editing.md index 2371a635f..bcca3faed 100644 --- a/website/i18n/hi/docusaurus-plugin-content-docs/current/addons/image-editing.md +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/addons/image-editing.md @@ -23,6 +23,26 @@ इसे उत्कीर्णन ऑपरेशन में लिथोफ़ेन और उभार के लिए उपयोग करें, जहाँ उजले क्षेत्र गहरे उकेरे जाने चाहिए, या गहरी पृष्ठभूमि पर उजली आकृति उकेरने के लिए. +## छवि समायोजित करें + +एक या अधिक वर्कपीस चुनें और किसी फ़ोटो को एनग्रेव या ट्रेस करने से पहले ठीक करने के लिए **Tools - +Adjust Image...** चुनें: + +- **Brightness** (चमक) छवि को हल्का या गहरा करता है। +- **Contrast** (कंट्रास्ट) टोन को मध्य-ग्रे से दूर फैलाता है या उसकी ओर खींचता है। +- **Gamma** 1 से ऊपर मध्य-टोन को हल्का करता है, 1 से नीचे उन्हें गहरा करता है; काला और सफ़ेद जैसे + हैं वैसे ही रहते हैं। +- **Sharpen** (शार्पन) किनारों और बारीक विवरणों को मज़बूत करता है (एक अनशार्प मास्क)। **Sharpen + Radius** उन विवरणों का आकार मिलीमीटर में तय करता है, इसलिए परिणाम किसी भी लाइन अंतराल या ज़ूम पर + एक जैसा रहता है। + +संपादन करते समय कैनवास बदलाव दिखाता है। **Apply** उन्हें एक ही अनडू चरण के रूप में रखता है; बिना +लागू किए डायलॉग बंद करने पर वे हट जाते हैं, और **Reset** हर मान को फिर से तटस्थ कर देता है। पारदर्शी +क्षेत्र पारदर्शी ही रहते हैं। + +डायलॉग पहले चुने गए वर्कपीस के मानों के साथ खुलता है, और Apply हर चुने गए वर्कपीस को वही मान देता +है। शार्पनिंग टोन सुधारों से पहले होती है, और Invert Image जैसे अन्य संपादन अपनी जगह पर रहते हैं। + ## पुराने प्रोजेक्ट इस ऐड-ऑन से पहले, उलटें उत्कीर्णन ऑपरेशन की एक सेटिंग थी. जब आप कोई पुराना प्रोजेक्ट खोलते हैं जिसने diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/addons/image-editing.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/addons/image-editing.md index 98382cf0c..15fbba9cd 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/addons/image-editing.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/addons/image-editing.md @@ -23,6 +23,27 @@ invertidas são invertidas, de modo que toda a seleção termine igual. Use para litofanias e embossing em uma operação de gravação, onde áreas claras devem ser gravadas em profundidade, ou para gravar arte clara em um fundo escuro. +## Ajustar imagem + +Selecione uma ou mais peças e escolha **Tools - Adjust Image...** para corrigir uma foto antes de +gravá-la ou vetorizá-la: + +- **Brightness** (brilho) clareia ou escurece a imagem. +- **Contrast** (contraste) afasta os tons do cinza médio ou os aproxima dele. +- **Gamma** acima de 1 clareia os tons médios e abaixo de 1 os escurece; o preto e o branco não + mudam. +- **Sharpen** (nitidez) reforça bordas e detalhes finos (uma máscara de nitidez). **Sharpen Radius** + define o tamanho desses detalhes em milímetros, então o resultado é o mesmo em qualquer intervalo + de linha ou zoom. + +A tela mostra as alterações enquanto você edita. **Apply** as mantém como uma única etapa de +desfazer; fechar a caixa de diálogo sem aplicar as descarta, e **Reset** volta todos os valores ao +neutro. As áreas transparentes continuam transparentes. + +A caixa de diálogo abre com os valores da primeira peça selecionada, e Apply dá os mesmos valores a +todas as peças selecionadas. A nitidez é aplicada antes das correções de tom, e outras edições como +Invert Image mantêm seu lugar. + ## Projetos mais antigos Antes deste addon, Inverter era uma configuração da operação de gravação. Quando você abre um diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/addons/image-editing.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/addons/image-editing.md index 5edefeebc..35a18dd3a 100644 --- a/website/i18n/uk/docusaurus-plugin-content-docs/current/addons/image-editing.md +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/addons/image-editing.md @@ -23,6 +23,26 @@ Використовуйте це для літофаній і тиснення в операції гравіювання, де світлі області мають гравіюватися глибоко, або для гравіювання світлого зображення на темному тлі. +## Коригування зображення + +Виберіть одну або кілька заготовок і виберіть **Tools - Adjust Image...**, щоб виправити фото перед +гравіюванням або трасуванням: + +- **Brightness** (яскравість) освітлює або затемнює зображення. +- **Contrast** (контраст) розводить тони від середньо-сірого або зводить їх до нього. +- **Gamma** понад 1 освітлює середні тони, менше 1 затемнює їх; чорний і білий не змінюються. +- **Sharpen** (різкість) підсилює краї та дрібні деталі (нерізке маскування). **Sharpen Radius** + задає розмір цих деталей у міліметрах, тож результат однаковий за будь-якого інтервалу ліній чи + масштабу. + +Полотно показує зміни під час редагування. **Apply** зберігає їх як один крок скасування; якщо +закрити діалог без застосування, вони відкидаються, а **Reset** повертає всі значення до +нейтральних. Прозорі ділянки залишаються прозорими. + +Діалог відкривається зі значеннями першої вибраної заготовки, а Apply надає всім вибраним заготовкам +однакові значення. Різкість застосовується перед корекцією тонів, а інші редагування, як-от Invert +Image, залишаються на своєму місці. + ## Старіші проєкти До цього розширення інверсія була налаштуванням операції гравіювання. Коли ви відкриваєте старіший diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/addons/image-editing.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/addons/image-editing.md index 0b9423fac..4c199495f 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/addons/image-editing.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/addons/image-editing.md @@ -17,6 +17,22 @@ Image**。浅色区域变深,深色区域变浅;透明区域保持透明, 在雕刻操作中将其用于石版画和浮雕(浅色区域应雕刻得较深),或者在深色背景上雕刻浅色图案。 +## 调整图像 + +选择一个或多个工件,然后选择 **Tools - Adjust Image...**,在雕刻或描摹之前校正照片: + +- **Brightness**(亮度)使图像变亮或变暗。 +- **Contrast**(对比度)使色调远离或靠近中灰。 +- **Gamma** 大于 1 时提亮中间调,小于 1 时压暗中间调;黑色和白色保持不变。 +- **Sharpen**(锐化)增强边缘和细节(USM 锐化)。**Sharpen Radius** + 以毫米为单位设置这些细节的大小,因此无论行间距或缩放如何,结果都相同。 + +编辑时画布会实时显示更改。**Apply** 将其作为一个撤销步骤保留;不应用就关闭对话框会放弃更改, +**Reset** 将所有值恢复为中性。透明区域保持透明。 + +对话框打开时显示第一个所选工件的值,Apply 会为所有所选工件设置相同的值。锐化在色调校正之前进行,Invert +Image 等其他编辑保持原位。 + ## 旧项目 在此插件出现之前,反转是雕刻操作的一个设置。当您打开使用过该设置的旧项目时,Rayforge 会将该设置移动到该图层的工件上,因此输出保持不变。 From 6dcfdf2820aef4ff2d5d5f218d9ba14d4169e5d5 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Tue, 13 Oct 2026 13:27:45 +0200 Subject: [PATCH 070/104] perf(image-edit): bound the workpiece and source image caches The render and processed-image caches on a workpiece grew by one image per zoom level and per resize, and the source asset's base-image cache by up to ten images regardless of their size. On an 8000x6000 image ten zoom steps and ten resizes kept about 2.9 GB of pixels cached. Add a small thread-safe LruCache with an entry limit and a byte budget, and use it for all three caches: at most 4 workpiece renders and 4 processed images (10 base images per source), each cache limited to 128 MiB of pixels, least recently used first; the newest image is always kept. Edit changes still replace the processed cache instead of adding to it, so slider moves in the Adjust Image dialog keep a single cached result. Refs #515 --- .../tests/ui_gtk/test_adjust_dialog.py | 25 ++++++ rayforge/core/image_cache.py | 43 ++++++++++ rayforge/core/source_asset.py | 19 ++--- rayforge/core/workpiece.py | 19 +++-- rayforge/shared/util/cache.py | 78 ++++++++++++++++++- tests/core/test_workpiece_image_edits.py | 60 ++++++++++++++ tests/shared/test_shared_util_cache.py | 49 +++++++++++- 7 files changed, 274 insertions(+), 19 deletions(-) create mode 100644 rayforge/core/image_cache.py diff --git a/rayforge/builtin_addons/rayforge-addon-image-editing/tests/ui_gtk/test_adjust_dialog.py b/rayforge/builtin_addons/rayforge-addon-image-editing/tests/ui_gtk/test_adjust_dialog.py index 56e9cb98b..f84ddb2df 100644 --- a/rayforge/builtin_addons/rayforge-addon-image-editing/tests/ui_gtk/test_adjust_dialog.py +++ b/rayforge/builtin_addons/rayforge-addon-image-editing/tests/ui_gtk/test_adjust_dialog.py @@ -3,6 +3,7 @@ import gi import pytest +import pyvips gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") @@ -80,3 +81,27 @@ def test_reset_removes_the_adjustments(dialog, workpieces): dialog.apply() assert workpieces[0].image_edits == () assert workpieces[1].image_edits == (INVERT,) + + +def _cache_a_render(wp: WorkPiece) -> None: + wp._processed_cache.put((10, 10, wp.size), pyvips.Image.black(10, 10)) + + +@pytest.mark.ui +def test_previews_do_not_accumulate_cached_images(dialog, workpieces): + for value in range(10): + for wp in workpieces: + _cache_a_render(wp) + dialog.brightness_row.set_value(value) + dialog.preview() + assert all(len(wp._processed_cache) == 0 for wp in workpieces) + + +@pytest.mark.ui +def test_closing_drops_the_preview_images(dialog, workpieces): + dialog.sharpen_row.set_value(100) + dialog.preview() + for wp in workpieces: + _cache_a_render(wp) + dialog.close() + assert all(len(wp._processed_cache) == 0 for wp in workpieces) diff --git a/rayforge/core/image_cache.py b/rayforge/core/image_cache.py new file mode 100644 index 000000000..c872a0a1d --- /dev/null +++ b/rayforge/core/image_cache.py @@ -0,0 +1,43 @@ +""" +Retention policy of the in-memory image caches of workpieces and +source assets: each cache keeps at most a few images and a byte budget, +evicting the least recently used image first. The newest image is +always kept, even when it alone exceeds the budget. +""" + +import pyvips + +from ..shared.util.cache import LruCache + +IMAGE_CACHE_MAX_ENTRIES = 4 +IMAGE_CACHE_MAX_BYTES = 128 * 1024 * 1024 + +_VIPS_FORMAT_BYTES = { + "uchar": 1, + "char": 1, + "ushort": 2, + "short": 2, + "uint": 4, + "int": 4, + "float": 4, + "complex": 8, + "double": 8, + "dpcomplex": 16, +} + + +def image_nbytes(image: pyvips.Image) -> int: + """The uncompressed size of the image's pixels in bytes.""" + sample = _VIPS_FORMAT_BYTES.get(image.format, 8) + return image.width * image.height * image.bands * sample + + +def new_image_cache( + max_entries: int = IMAGE_CACHE_MAX_ENTRIES, +) -> LruCache: + """An LRU cache of images with the shared byte budget.""" + return LruCache( + max_entries=max_entries, + max_bytes=IMAGE_CACHE_MAX_BYTES, + sizeof=image_nbytes, + ) diff --git a/rayforge/core/source_asset.py b/rayforge/core/source_asset.py index 8f5b6f1c7..515c035df 100644 --- a/rayforge/core/source_asset.py +++ b/rayforge/core/source_asset.py @@ -4,7 +4,6 @@ import hashlib import logging import uuid -from collections import OrderedDict from dataclasses import dataclass, field from gettext import gettext as _ from pathlib import Path @@ -13,7 +12,9 @@ import pyvips from blinker import Signal +from ..shared.util.cache import LruCache from .asset import IAsset +from .image_cache import new_image_cache logger = logging.getLogger(__name__) @@ -58,8 +59,12 @@ class SourceAsset(IAsset): _hidden: bool = field(init=False, default=False) _updated: Signal = field(init=False, default_factory=Signal) extra: dict[str, Any] = field(default_factory=dict) - _base_image_cache: OrderedDict = field( - init=False, default_factory=OrderedDict, repr=False + _base_image_cache: LruCache = field( + init=False, + default_factory=lambda: new_image_cache( + SourceAsset._BASE_IMAGE_CACHE_MAX_SIZE + ), + repr=False, ) _BASE_IMAGE_CACHE_MAX_SIZE: ClassVar[int] = 10 @@ -77,12 +82,8 @@ def cache_base_image( self, data_id: int, width: int, height: int, image: pyvips.Image ) -> None: key = (data_id, width, height) - if key in self._base_image_cache: - self._base_image_cache.move_to_end(key) - else: - self._base_image_cache[key] = image - while len(self._base_image_cache) > self._BASE_IMAGE_CACHE_MAX_SIZE: - self._base_image_cache.popitem(last=False) + if self._base_image_cache.get(key) is None: + self._base_image_cache.put(key, image) def clear_base_image_cache(self) -> None: self._base_image_cache.clear() diff --git a/rayforge/core/workpiece.py b/rayforge/core/workpiece.py index c443d0e5f..9fc954308 100644 --- a/rayforge/core/workpiece.py +++ b/rayforge/core/workpiece.py @@ -26,8 +26,10 @@ from raygeo.ops.part import Part from ..context import get_context +from ..shared.util.cache import LruCache from .asset_registry import asset_type_registry from .geometry_provider import IGeometryProvider +from .image_cache import new_image_cache from .image_edit import ImageEditStep, apply_image_edits from .item import DocItem from .source_asset_segment import SourceAssetSegment @@ -104,14 +106,16 @@ def __init__( self._edited_boundaries: Geometry | None = None # The cache for rendered vips images. Key is (width, height). - self._render_cache: dict[tuple[int, int], pyvips.Image] = {} + self._render_cache: LruCache[tuple[int, int], pyvips.Image] = ( + new_image_cache() + ) # The non-destructive image edit stack and the cache of rendered # images with that stack applied. Key is (width, height, size). self._image_edits: tuple[ImageEditStep, ...] = () - self._processed_cache: dict[ + self._processed_cache: LruCache[ tuple[int, int, tuple[float, float]], pyvips.Image - ] = {} + ] = new_image_cache() # Transient attributes for deserialized instances in subprocesses self._data: bytes | None = None @@ -871,7 +875,7 @@ def get_processed_image( processed = apply_image_edits( _with_resolution(image, size), self._image_edits ) - self._processed_cache[key] = processed + self._processed_cache.put(key, processed) return processed def _get_vips_image(self, width: int, height: int) -> pyvips.Image | None: @@ -880,8 +884,9 @@ def _get_vips_image(self, width: int, height: int) -> pyvips.Image | None: Orchestrates data retrieval, rendering, cropping, and masking. """ key = (width, height) - if key in self._render_cache: - return self._render_cache[key] + cached = self._render_cache.get(key) + if cached is not None: + return cached # 1. Resolve Context ctx = self._resolve_render_context() @@ -923,7 +928,7 @@ def _get_vips_image(self, width: int, height: int) -> pyvips.Image | None: ) if final_image: - self._render_cache[key] = final_image + self._render_cache.put(key, final_image) return final_image diff --git a/rayforge/shared/util/cache.py b/rayforge/shared/util/cache.py index 458bd8337..93f51a046 100644 --- a/rayforge/shared/util/cache.py +++ b/rayforge/shared/util/cache.py @@ -1,6 +1,11 @@ -from collections.abc import Callable +import threading +from collections import OrderedDict +from collections.abc import Callable, Hashable, Iterator from functools import lru_cache, wraps -from typing import Any +from typing import Any, Generic, TypeVar + +K = TypeVar("K", bound=Hashable) +V = TypeVar("V") def lru_cache_unless_forced(maxsize: int = 128): @@ -27,3 +32,72 @@ def wrapper(*args, **kwargs) -> Any: return wrapper return decorator + + +class LruCache(Generic[K, V]): + """ + A thread-safe mapping that keeps at most ``max_entries`` values and, + when ``max_bytes`` is set, at most that many bytes as measured by + ``sizeof``. The least recently used values are evicted first; the + newest value is always kept, even when it alone exceeds the budget. + """ + + def __init__( + self, + max_entries: int, + max_bytes: int | None = None, + sizeof: Callable[[V], int] | None = None, + ): + self.max_entries = max(1, max_entries) + self.max_bytes = max_bytes + self._sizeof = sizeof or (lambda value: 0) + self._entries: OrderedDict[K, tuple[V, int]] = OrderedDict() + self._nbytes = 0 + self._lock = threading.Lock() + + def __len__(self) -> int: + return len(self._entries) + + def __contains__(self, key: object) -> bool: + return key in self._entries + + def keys(self) -> Iterator[K]: + return iter(list(self._entries)) + + @property + def nbytes(self) -> int: + """The total size of the cached values.""" + return self._nbytes + + def get(self, key: K) -> V | None: + with self._lock: + entry = self._entries.get(key) + if entry is None: + return None + self._entries.move_to_end(key) + return entry[0] + + def put(self, key: K, value: V) -> None: + size = self._sizeof(value) + with self._lock: + old = self._entries.pop(key, None) + if old is not None: + self._nbytes -= old[1] + self._entries[key] = (value, size) + self._nbytes += size + self._evict() + + def clear(self) -> None: + with self._lock: + self._entries.clear() + self._nbytes = 0 + + def _over_budget(self) -> bool: + if len(self._entries) > self.max_entries: + return True + return self.max_bytes is not None and self._nbytes > self.max_bytes + + def _evict(self) -> None: + while len(self._entries) > 1 and self._over_budget(): + _, (_, size) = self._entries.popitem(last=False) + self._nbytes -= size diff --git a/tests/core/test_workpiece_image_edits.py b/tests/core/test_workpiece_image_edits.py index 0df99d213..f9789cafc 100644 --- a/tests/core/test_workpiece_image_edits.py +++ b/tests/core/test_workpiece_image_edits.py @@ -6,6 +6,11 @@ import pyvips from rayforge.core.doc import Doc +from rayforge.core.image_cache import ( + IMAGE_CACHE_MAX_BYTES, + IMAGE_CACHE_MAX_ENTRIES, + image_nbytes, +) from rayforge.core.image_edit import ( ImageEditOp, ImageEditStep, @@ -176,3 +181,58 @@ def test_resizing_reapplies_the_edits(workpiece, resolution_op): pytest.approx((2.0, 2.0)), pytest.approx((1.0, 1.0)), ] + + +def test_slider_changes_keep_one_processed_image(workpiece, blank_op): + for value in range(20): + workpiece.image_edits = [ImageEditStep(blank_op.NAME, {"v": value})] + workpiece.get_processed_image(20, 20) + assert len(workpiece._processed_cache) == 1 + + +def test_zooming_keeps_a_bounded_number_of_images(workpiece, blank_op): + workpiece.image_edits = [ImageEditStep(blank_op.NAME)] + for zoom in range(1, 11): + workpiece.get_processed_image(10 * zoom, 10 * zoom) + assert len(workpiece._render_cache) == IMAGE_CACHE_MAX_ENTRIES + assert len(workpiece._processed_cache) == IMAGE_CACHE_MAX_ENTRIES + assert (100, 100) in workpiece._render_cache + assert (10, 10) not in workpiece._render_cache + + +def test_resizing_keeps_a_bounded_number_of_images(workpiece, blank_op): + workpiece.image_edits = [ImageEditStep(blank_op.NAME)] + for step in range(1, 11): + workpiece.set_size(10.0 * step, 10.0) + workpiece.get_processed_image(20, 20) + assert len(workpiece._processed_cache) <= IMAGE_CACHE_MAX_ENTRIES + + +def test_image_caches_have_a_byte_budget(workpiece): + for cache in (workpiece._render_cache, workpiece._processed_cache): + assert cache.max_bytes == IMAGE_CACHE_MAX_BYTES + + +def test_byte_budget_evicts_the_least_recently_used(workpiece): + workpiece._render_cache.max_bytes = 3600 + workpiece._get_vips_image(20, 20) + workpiece._get_vips_image(10, 10) + workpiece._get_vips_image(20, 20) + workpiece._get_vips_image(22, 22) + assert set(workpiece._render_cache.keys()) == {(20, 20), (22, 22)} + assert workpiece._render_cache.nbytes <= 3600 + + +def test_cached_size_is_the_uncompressed_pixel_size(): + image = pyvips.Image.black(30, 20, bands=4).cast("ushort") + assert image_nbytes(image) == 30 * 20 * 4 * 2 + + +def test_source_base_images_have_a_byte_budget(workpiece): + source = workpiece.source + assert source is not None + assert source._base_image_cache.max_bytes == IMAGE_CACHE_MAX_BYTES + big = pyvips.Image.black(4000, 3000, bands=4) + for step in range(10): + source.cache_base_image(step, 4000, 3000, big) + assert source._base_image_cache.nbytes <= IMAGE_CACHE_MAX_BYTES diff --git a/tests/shared/test_shared_util_cache.py b/tests/shared/test_shared_util_cache.py index 681ae3337..b7d8e51f1 100644 --- a/tests/shared/test_shared_util_cache.py +++ b/tests/shared/test_shared_util_cache.py @@ -1,4 +1,4 @@ -from rayforge.shared.util.cache import lru_cache_unless_forced +from rayforge.shared.util.cache import LruCache, lru_cache_unless_forced class TestLruCacheUnlessForced: @@ -110,3 +110,50 @@ def my_function(x): return x assert my_function.__name__ == "my_function" + + +class TestLruCache: + def test_get_returns_stored_values(self): + cache = LruCache(max_entries=2) + cache.put("a", 1) + assert cache.get("a") == 1 + assert cache.get("missing") is None + assert "a" in cache + + def test_evicts_least_recently_used_entry(self): + cache = LruCache(max_entries=2) + cache.put("a", 1) + cache.put("b", 2) + cache.get("a") + cache.put("c", 3) + assert set(cache.keys()) == {"a", "c"} + + def test_byte_budget_evicts_least_recently_used(self): + cache = LruCache(max_entries=10, max_bytes=100, sizeof=lambda v: v) + cache.put("a", 40) + cache.put("b", 40) + cache.get("a") + cache.put("c", 40) + assert set(cache.keys()) == {"a", "c"} + assert cache.nbytes == 80 + + def test_keeps_newest_entry_even_over_budget(self): + cache = LruCache(max_entries=10, max_bytes=100, sizeof=lambda v: v) + cache.put("a", 40) + cache.put("big", 500) + assert list(cache.keys()) == ["big"] + assert cache.nbytes == 500 + + def test_replacing_a_key_updates_the_size(self): + cache = LruCache(max_entries=10, max_bytes=100, sizeof=lambda v: v) + cache.put("a", 40) + cache.put("a", 10) + assert len(cache) == 1 + assert cache.nbytes == 10 + + def test_clear_empties_the_cache(self): + cache = LruCache(max_entries=10, max_bytes=100, sizeof=lambda v: v) + cache.put("a", 40) + cache.clear() + assert len(cache) == 0 + assert cache.nbytes == 0 From 97c96ce5cda3d8db5bf8f8e72239e2bc99a2e31c Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Mon, 12 Oct 2026 19:18:58 +0200 Subject: [PATCH 071/104] feat(sketcher): add a Polygon tool for regular polygons and stars The tool (G+X) draws from the center: the pointer sets the radius and the rotation of the first corner. Ctrl makes a star, and the dimension input takes the radius, the corner count (default 6) and the star's inner radius ratio. The result is plain points and lines, kept regular by constraints: equal edge lengths, corners on a construction circle around the center point, a second circle and an aspect ratio for a star's inner corners, and a radius constraint when one was typed. Refs #522 --- CHANGELOG.md | 2 + .../sketcher/core/commands/__init__.py | 8 + .../sketcher/core/commands/polygon.py | 354 ++++++++++++++++++ .../sketcher/ui_gtk/menu.py | 1 + .../sketcher/ui_gtk/tools/__init__.py | 3 + .../sketcher/ui_gtk/tools/polygon_tool.py | 299 +++++++++++++++ .../tests/core/commands/test_polygon_cmd.py | 216 +++++++++++ .../tests/ui_gtk/tools/test_polygon_tool.py | 154 ++++++++ .../icons/sketch-polygon-symbolic.svg | 15 + website/docs/features/sketcher/index.md | 2 + website/docs/features/sketcher/polygon.md | 36 ++ website/sidebars.js | 1 + 12 files changed, 1091 insertions(+) create mode 100644 rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/polygon.py create mode 100644 rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/polygon_tool.py create mode 100644 rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_polygon_cmd.py create mode 100644 rayforge/builtin_addons/rayforge-addon-sketcher/tests/ui_gtk/tools/test_polygon_tool.py create mode 100644 rayforge/resources/icons/sketch-polygon-symbolic.svg create mode 100644 website/docs/features/sketcher/polygon.md diff --git a/CHANGELOG.md b/CHANGELOG.md index d16ae179e..f3a333cb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 operations). The first edit is Tools > Invert Image (#515) - Addons: the `register_image_edits` hook and image edit registry (plugin API 23) let addons provide their own image edits +- Sketcher: Polygon tool (G+X) for regular polygons and stars, made of + plain points and lines that constraints keep regular (#522) ### Changed diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/__init__.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/__init__.py index e8ede2414..45e1f5f59 100644 --- a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/__init__.py +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/__init__.py @@ -35,6 +35,11 @@ MovePointCommand, UnstickJunctionCommand, ) +from .polygon import ( + PolygonCommand, + PolygonPreviewState, + polygon_vertices, +) from .rectangle import RectangleCommand, RectanglePreviewState from .rounded_rect import RoundedRectCommand, RoundedRectPreviewState from .straighten import StraightenBezierCommand @@ -88,6 +93,8 @@ "MoveEntitiesCommand", "MovePointCommand", "OffsetCommand", + "PolygonCommand", + "PolygonPreviewState", "PreviewState", "RectangleCommand", "RectanglePreviewState", @@ -106,4 +113,5 @@ "TextBoxCommand", "ToggleConstructionCommand", "UnstickJunctionCommand", + "polygon_vertices", ] diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/polygon.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/polygon.py new file mode 100644 index 000000000..da191aa86 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/polygon.py @@ -0,0 +1,354 @@ +from __future__ import annotations + +import math +from collections.abc import Iterator +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from raygeo.geo.types import Point as GeoPoint + +from ..constraints import ( + AspectRatioConstraint, + Constraint, + EqualLengthConstraint, + PointOnLineConstraint, + RadiusConstraint, +) +from ..entities import Circle, Entity, Line, Point +from ..types import EntityID +from .base import PreviewState, SketchChangeCommand +from .dimension import DimensionData +from .items import AddItemsCommand + +if TYPE_CHECKING: + from ..registry import EntityRegistry + from ..sketch import Sketch + +MIN_SIDES = 3 +MAX_SIDES = 100 +DEFAULT_SIDES = 6 +DEFAULT_STAR_RATIO = 0.5 + + +def check_shape(sides: int, star_ratio: float | None) -> None: + """Raises ValueError for a corner count or star ratio out of range.""" + if not MIN_SIDES <= sides <= MAX_SIDES: + raise ValueError( + f"A polygon needs {MIN_SIDES} to {MAX_SIDES} corners, got {sides}" + ) + if star_ratio is not None and not 0.0 < star_ratio < 1.0: + raise ValueError(f"Star ratio must be between 0 and 1: {star_ratio}") + + +def polygon_vertices( + center: GeoPoint, + first: GeoPoint, + sides: int, + star_ratio: float | None = None, +) -> list[GeoPoint]: + """ + The corners of a regular polygon around ``center`` whose first + corner is ``first``. A star alternates those outer corners with + inner ones at ``star_ratio`` times the radius, halfway in between. + """ + cx, cy = center + radius = math.hypot(first[0] - cx, first[1] - cy) + start = math.atan2(first[1] - cy, first[0] - cx) + step = 2.0 * math.pi / sides + vertices: list[GeoPoint] = [] + for k in range(sides): + angle = start + k * step + vertices.append( + (cx + radius * math.cos(angle), cy + radius * math.sin(angle)) + ) + if star_ratio is not None: + inner = radius * star_ratio + angle += step / 2.0 + vertices.append( + (cx + inner * math.cos(angle), cy + inner * math.sin(angle)) + ) + return vertices + + +def _ring(ids: list[EntityID]) -> Iterator[tuple[EntityID, EntityID]]: + return zip(ids, ids[1:] + ids[:1]) + + +class PolygonPreviewState(PreviewState): + """Preview state for the polygon tool's 2-click workflow.""" + + def __init__( + self, + center_id: EntityID, + center_temp: bool, + radius_id: EntityID, + sides: int = DEFAULT_SIDES, + star_ratio: float | None = None, + ): + self.center_id = center_id + self.center_temp = center_temp + self.radius_id = radius_id + self.sides = sides + self.star_ratio = star_ratio + self.corner_ids: list[EntityID] = [] + self.line_ids: list[EntityID] = [] + self.locked_radius: float | None = None + + def get_preview_point_ids(self) -> set[EntityID]: + """The preview's own points, which must not be snapped to.""" + return {self.radius_id, *self.corner_ids} + + def _vertices(self, registry: EntityRegistry) -> list[GeoPoint]: + center = registry.get_point(self.center_id) + first = registry.get_point(self.radius_id) + return polygon_vertices( + (center.x, center.y), + (first.x, first.y), + self.sides, + self.star_ratio, + ) + + def _remove_outline(self, registry: EntityRegistry) -> None: + lines = set(self.line_ids) + corners = set(self.corner_ids) + registry.entities = [e for e in registry.entities if e.id not in lines] + registry._entity_map = {e.id: e for e in registry.entities} + registry.points = [p for p in registry.points if p.id not in corners] + self.line_ids = [] + self.corner_ids = [] + + def rebuild(self, registry: EntityRegistry) -> None: + """Recreates the corner points and lines for the current shape.""" + self._remove_outline(registry) + vertices = self._vertices(registry) + self.corner_ids = [registry.add_point(x, y) for x, y in vertices[1:]] + ids = [self.radius_id, *self.corner_ids] + self.line_ids = [registry.add_line(a, b) for a, b in _ring(ids)] + + def reposition(self, registry: EntityRegistry) -> None: + """Moves the corner points to follow the radius point.""" + if len(self.corner_ids) + 1 != len(self._vertices(registry)): + self.rebuild(registry) + return + for pid, (x, y) in zip(self.corner_ids, self._vertices(registry)[1:]): + point = registry.get_point(pid) + point.x, point.y = x, y + + def set_shape( + self, registry: EntityRegistry, sides: int, star_ratio: float | None + ) -> None: + """Changes the corner count or star ratio of the preview.""" + check_shape(sides, star_ratio) + self.sides = sides + self.star_ratio = star_ratio + self.rebuild(registry) + + def set_radius(self, registry: EntityRegistry, radius: float) -> None: + """Locks the circumradius from numeric input.""" + self.locked_radius = radius + center = registry.get_point(self.center_id) + first = registry.get_point(self.radius_id) + _place_at_radius(center, first, first.x, first.y, radius) + self.reposition(registry) + + def get_dimensions(self, registry: EntityRegistry) -> list[DimensionData]: + """Radius, corner count and star ratio, in dimension-field order.""" + try: + center = registry.get_point(self.center_id) + first = registry.get_point(self.radius_id) + except IndexError: + return [] + radius = math.hypot(first.x - center.x, first.y - center.y) + ratio = "-" if self.star_ratio is None else f"{self.star_ratio:g}" + return [ + DimensionData( + label=f"R{DimensionData.format_length(radius)}", + position=( + (center.x + first.x) / 2.0, + (center.y + first.y) / 2.0, + ), + ), + DimensionData( + label=f"{self.sides}", position=(center.x, center.y) + ), + DimensionData(label=ratio, position=(first.x, first.y)), + ] + + +def _place_at_radius( + center: Point, point: Point, x: float, y: float, radius: float +) -> None: + dx, dy = x - center.x, y - center.y + length = math.hypot(dx, dy) + if length < 1e-9: + dx, dy, length = 1.0, 0.0, 1.0 + point.x = center.x + dx / length * radius + point.y = center.y + dy / length * radius + + +class PolygonCommand(SketchChangeCommand): + """ + Creates a regular polygon or star as plain points and lines. The + corners sit on construction circles around the center point and all + edges have equal length, so the shape stays regular when edited. + """ + + def __init__( + self, + sketch: Sketch, + center_id: EntityID, + end_pos: GeoPoint, + sides: int = DEFAULT_SIDES, + star_ratio: float | None = None, + is_center_temp: bool = False, + fixed_radius: float | None = None, + ): + check_shape(sides, star_ratio) + super().__init__(sketch, _("Add Polygon")) + self.center_id = center_id + self.end_pos = end_pos + self.sides = sides + self.star_ratio = star_ratio + self.is_center_temp = is_center_temp + self.fixed_radius = fixed_radius + self.add_cmd: AddItemsCommand | None = None + + @staticmethod + def start_preview( + registry: EntityRegistry, + x: float, + y: float, + snapped_pid: EntityID | None = None, + sides: int = DEFAULT_SIDES, + star_ratio: float | None = None, + **kwargs, + ) -> PolygonPreviewState: + """Creates the preview: the center and the first corner.""" + if snapped_pid is not None: + center_id, center_temp = snapped_pid, False + else: + center_id, center_temp = registry.add_point(x, y), True + state = PolygonPreviewState( + center_id, + center_temp, + registry.add_point(x, y), + sides=sides, + star_ratio=star_ratio, + ) + state.rebuild(registry) + return state + + @staticmethod + def update_preview( + registry: EntityRegistry, + preview_state: PreviewState, + x: float, + y: float, + ) -> None: + """Moves the first corner, which sets radius and rotation.""" + if not isinstance(preview_state, PolygonPreviewState): + raise TypeError("Expected PolygonPreviewState") + try: + center = registry.get_point(preview_state.center_id) + first = registry.get_point(preview_state.radius_id) + except IndexError: + return + if preview_state.locked_radius is not None: + _place_at_radius(center, first, x, y, preview_state.locked_radius) + else: + first.x, first.y = x, y + preview_state.reposition(registry) + + @staticmethod + def cleanup_preview( + registry: EntityRegistry, preview_state: PreviewState + ) -> None: + """ + Removes the preview's corners and lines. Like the circle tool, + it leaves a temporary center point for the tool to remove. + """ + if not isinstance(preview_state, PolygonPreviewState): + raise TypeError("Expected PolygonPreviewState") + preview_state._remove_outline(registry) + registry.points = [ + p for p in registry.points if p.id != preview_state.radius_id + ] + + def _shape( + self, center: Point + ) -> tuple[list[Point], list[Entity], list[Constraint]]: + temp_ids = iter(range(-2, -10_000, -1)) + vertices = polygon_vertices( + (center.x, center.y), self.end_pos, self.sides, self.star_ratio + ) + if self.fixed_radius is not None: + first = Point(0, *vertices[0]) + _place_at_radius( + center, first, first.x, first.y, self.fixed_radius + ) + vertices = polygon_vertices( + (center.x, center.y), + (first.x, first.y), + self.sides, + self.star_ratio, + ) + points = [Point(next(temp_ids), x, y) for x, y in vertices] + ids = [p.id for p in points] + lines = [Line(next(temp_ids), a, b) for a, b in _ring(ids)] + stride = 1 if self.star_ratio is None else 2 + rings = [ids[0::stride]] + if self.star_ratio is not None: + rings.append(ids[1::2]) + circles = [ + Circle(next(temp_ids), center.id, ring[0], construction=True) + for ring in rings + ] + constraints: list[Constraint] = [ + EqualLengthConstraint([line.id for line in lines]) + ] + for circle, ring in zip(circles, rings): + constraints += [ + PointOnLineConstraint(pid, circle.id) for pid in ring[1:] + ] + if self.star_ratio is not None: + constraints.append( + AspectRatioConstraint( + center.id, ids[1], center.id, ids[0], self.star_ratio + ) + ) + if self.fixed_radius is not None: + constraints.append( + RadiusConstraint(circles[0].id, self.fixed_radius) + ) + return points, [*lines, *circles], constraints + + def _do_execute(self) -> None: + if self.add_cmd: + return self.add_cmd._do_execute() + registry = self.sketch.registry + try: + center = registry.get_point(self.center_id) + except IndexError: + return + if math.hypot( + self.end_pos[0] - center.x, self.end_pos[1] - center.y + ) < 1e-9 and (self.fixed_radius is None): + if self.is_center_temp: + self.sketch.remove_point_if_unused(self.center_id) + return + points, entities, constraints = self._shape(center) + if self.is_center_temp: + registry.points.remove(center) + points.append(center) + self.add_cmd = AddItemsCommand( + self.sketch, + "", + points=points, + entities=entities, + constraints=constraints, + ) + self.add_cmd._do_execute() + + def _do_undo(self) -> None: + if self.add_cmd: + self.add_cmd._do_undo() diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/menu.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/menu.py index 9f43e8e6a..fba91b2c4 100644 --- a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/menu.py +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/menu.py @@ -39,6 +39,7 @@ def __init__(self): tools_group.append(_("Path"), "sketch.tool_path") tools_group.append(_("Rectangle"), "sketch.tool_rectangle") tools_group.append(_("Rounded Rectangle"), "sketch.tool_rounded_rect") + tools_group.append(_("Polygon"), "sketch.tool_polygon") tools_group.append(_("Fill Area"), "sketch.tool_fill") tools_menu.append_section(_("Tools"), tools_group) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/__init__.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/__init__.py index 104e41958..c5f77d560 100644 --- a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/__init__.py +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/__init__.py @@ -23,6 +23,7 @@ from .offset_tool import OffsetTool from .path_tool import PathTool from .perpendicular_constraint_tool import PerpendicularConstraintTool +from .polygon_tool import PolygonTool from .radius_constraint_tool import RadiusConstraintTool from .rectangle_tool import RectangleTool from .rounded_rect_tool import RoundedRectTool @@ -60,6 +61,7 @@ "offset": OffsetTool, "path": PathTool, "perpendicular": PerpendicularConstraintTool, + "polygon": PolygonTool, "radius": RadiusConstraintTool, "rectangle": RectangleTool, "rounded_rect": RoundedRectTool, @@ -133,6 +135,7 @@ def build_action_tool_map() -> dict[str, str]: "MirrorVerticalTool", "PathTool", "PerpendicularConstraintTool", + "PolygonTool", "RadiusConstraintTool", "RectangleTool", "RoundedRectTool", diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/polygon_tool.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/polygon_tool.py new file mode 100644 index 000000000..c8da558cc --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/polygon_tool.py @@ -0,0 +1,299 @@ +import math +from collections.abc import Callable +from gettext import gettext as _ +from typing import ClassVar + +import cairo + +from ...core.commands import PolygonCommand, PolygonPreviewState +from ...core.commands.polygon import ( + DEFAULT_SIDES, + DEFAULT_STAR_RATIO, + check_shape, +) +from .base import SketcherKey, SketchTool +from .dimension_input import DimensionInputHandler +from .snap_mixin import SnapMixin + +RADIUS_FIELD = 0 +SIDES_FIELD = 1 +STAR_FIELD = 2 + + +class PolygonTool(SnapMixin, SketchTool): + """Handles creating regular polygons and stars. + + - Default: click the center -> move -> click (drag also works); the + pointer sets the radius and the rotation of the first corner + - Ctrl: make it a star + - Type R, corner count and star ratio; Tab moves between fields + - Tab: toggle magnetic snap + """ + + ICON = "sketch-polygon-symbolic" + LABEL = _("Polygon") + SHORTCUTS: ClassVar[list[str]] = ["gx"] + CURSOR_ICON = "sketch-polygon-symbolic" + PIE_GROUP: ClassVar[str | None] = "rectangle" + + DRAG_THRESHOLD = 2.0 + + def __init__(self, element): + super().__init__(element) + self._preview_state: PolygonPreviewState | None = None + self._dim_input = DimensionInputHandler( + field_count=3, field_labels=[_("R"), _("N"), _("Star")] + ) + self._sides = DEFAULT_SIDES + self._typed_star_ratio: float | None = None + self._ctrl_held = False + self._press_world_pos: tuple[float, float] | None = None + self._in_press = False + + def is_available(self, target, target_type) -> bool: + return target is None + + def shortcut_is_active(self) -> bool: + return True + + def get_preview_state(self) -> PolygonPreviewState | None: + return self._preview_state + + def _star_ratio(self) -> float | None: + if self._typed_star_ratio is not None: + return self._typed_star_ratio + return DEFAULT_STAR_RATIO if self._ctrl_held else None + + def _discard_preview(self) -> None: + if self._preview_state is None: + return + center_id = self._preview_state.center_id + center_temp = self._preview_state.center_temp + PolygonCommand.cleanup_preview( + self.element.sketch.registry, self._preview_state + ) + self._preview_state = None + self.element.preview_changed.send(self.element) + if center_temp: + self.element.remove_point_if_unused(center_id) + + def on_deactivate(self): + """Clean up if the tool is deactivated mid-creation.""" + self._dim_input.cancel() + self._press_world_pos = None + self._in_press = False + self._typed_star_ratio = None + self._discard_preview() + self.element.mark_dirty() + self.clear_snap_result() + + def on_press(self, world_x: float, world_y: float, n_press: int) -> bool: + mx, my = self.element.hittester.screen_to_model( + world_x, world_y, self.element + ) + exclude_points = set() + if self._preview_state is not None: + exclude_points = self._preview_state.get_preview_point_ids() + mx, my = self.query_snap_for_creation( + self.element, mx, my, exclude_points + ) + if self._preview_state is None: + self._preview_state = PolygonCommand.start_preview( + self.element.sketch.registry, + mx, + my, + snapped_pid=self.get_snapped_point_id(), + sides=self._sides, + star_ratio=self._star_ratio(), + ) + self._press_world_pos = (world_x, world_y) + self._in_press = True + self.element.preview_changed.send(self.element) + self.element.mark_dirty() + else: + self._commit() + return True + + def on_release(self, world_x: float, world_y: float): + self._in_press = False + if self._preview_state is None or self._press_world_pos is None: + return + dx = world_x - self._press_world_pos[0] + dy = world_y - self._press_world_pos[1] + if math.hypot(dx, dy) < self.DRAG_THRESHOLD: + return + self._commit() + + def on_hover_motion(self, world_x: float, world_y: float): + """Updates the live preview of the polygon.""" + if self._preview_state is None: + self.clear_snap_result() + return + if self._dim_input.is_active(): + return + mx, my = self.element.hittester.screen_to_model( + world_x, world_y, self.element + ) + mx, my = self.query_snap_for_creation( + self.element, mx, my, self._preview_state.get_preview_point_ids() + ) + try: + PolygonCommand.update_preview( + self.element.sketch.registry, self._preview_state, mx, my + ) + self.element.mark_dirty() + except (IndexError, KeyError): + self.on_deactivate() + + def on_drag(self, world_dx: float, world_dy: float): + pass + + def on_modifier_change(self, shift: bool = False, ctrl: bool = False): + """Ctrl turns the preview into a star while it is held.""" + if self._ctrl_held == ctrl: + return + self._ctrl_held = ctrl + if self._preview_state is None: + return + self._preview_state.set_shape( + self.element.sketch.registry, self._sides, self._star_ratio() + ) + self.element.mark_dirty() + + def draw_overlay(self, ctx: cairo.Context): + """Draw snap feedback during creation.""" + if self._preview_state is not None: + self.draw_snap_feedback(ctx, self.element) + + def _commit(self, fixed_radius: float | None = None) -> None: + """Create the polygon the preview shows.""" + state = self._preview_state + if state is None: + return + registry = self.element.sketch.registry + try: + first = registry.get_point(state.radius_id) + except IndexError: + self.on_deactivate() + return + end_pos = (first.x, first.y) + sides, star_ratio = state.sides, state.star_ratio + PolygonCommand.cleanup_preview(registry, state) + self._preview_state = None + self._press_world_pos = None + self._in_press = False + self._typed_star_ratio = None + self._dim_input.cancel() + self.element.preview_changed.send(self.element) + cmd = PolygonCommand( + self.element.sketch, + state.center_id, + end_pos, + sides=sides, + star_ratio=star_ratio, + is_center_temp=state.center_temp, + fixed_radius=fixed_radius, + ) + self.element.execute_command(cmd) + self.element.mark_dirty() + self.clear_snap_result() + + def handle_text_input(self, text: str) -> bool: + """Handle numeric input for radius, corner count and star ratio.""" + if self._preview_state is None: + return False + if not self._dim_input.is_active(): + self._dim_input.start() + handled = self._dim_input.handle_text_input(text) + if handled: + self.element.mark_dirty() + return handled + + def _apply_field(self, field_index: int, value: float | None) -> None: + """Apply one typed value to the preview; invalid ones are ignored.""" + state = self._preview_state + if state is None or value is None: + return + registry = self.element.sketch.registry + if field_index == RADIUS_FIELD and value > 0: + state.set_radius(registry, value) + return + sides, star_ratio = state.sides, state.star_ratio + if field_index == SIDES_FIELD: + sides = round(value) + elif field_index == STAR_FIELD: + star_ratio = value + try: + check_shape(sides, star_ratio) + except ValueError: + return + self._sides = sides + if field_index == STAR_FIELD: + self._typed_star_ratio = star_ratio + state.set_shape(registry, sides, star_ratio) + + def _apply_dimension_input(self) -> None: + """Apply every typed value and create the polygon.""" + values = self._dim_input.commit() + if values is None or self._preview_state is None: + return + for index in (SIDES_FIELD, STAR_FIELD, RADIUS_FIELD): + if index < len(values): + self._apply_field(index, values[index]) + state = self._preview_state + fixed_radius = state.locked_radius if state is not None else None + self._commit(fixed_radius=fixed_radius) + + def _handle_tab(self, shift: bool) -> bool: + if not self._dim_input.is_active(): + self.toggle_magnetic_snap() + return True + handled, should_apply, field = self._dim_input.handle_tab(shift=shift) + if should_apply: + self._apply_dimension_input() + elif handled and field is not None: + self._apply_field(field, self._dim_input.get_field_value(field)) + self.element.mark_dirty() + return True + + def handle_key_event( + self, key: SketcherKey, shift: bool = False, ctrl: bool = False + ) -> bool: + """Handle special keys for dimension input.""" + if self._preview_state is None: + return False + if key == SketcherKey.TAB: + return self._handle_tab(shift) + if not self._dim_input.is_active(): + return False + if key == SketcherKey.BACKSPACE: + self._dim_input.handle_backspace() + elif key == SketcherKey.DELETE: + self._dim_input.handle_delete() + elif key == SketcherKey.RETURN: + self._apply_dimension_input() + elif key == SketcherKey.ESCAPE: + self._dim_input.cancel() + else: + return False + self.element.mark_dirty() + return True + + def get_active_shortcuts( + self, + ) -> list[tuple[str | list[str], str, Callable[[], bool] | None]]: + """Returns shortcuts for the status bar.""" + if self._preview_state is None: + return [] + if self._dim_input.is_active(): + return self._dim_input.get_active_shortcuts() + shortcuts: list[ + tuple[str | list[str], str, Callable[[], bool] | None] + ] = [ + ("Ctrl", _("Star"), None), + ("0-9", _("Type radius, corners and star ratio (R N Star)"), None), + ("Tab", _("Toggle Magnetic Snap"), None), + ] + if not self._in_press: + shortcuts.append(("Click", _("Set first corner"), None)) + return shortcuts diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_polygon_cmd.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_polygon_cmd.py new file mode 100644 index 000000000..b8735f091 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_polygon_cmd.py @@ -0,0 +1,216 @@ +import math + +import pytest +from sketcher.core import Sketch +from sketcher.core.commands import ( + PolygonCommand, + PolygonPreviewState, + polygon_vertices, +) +from sketcher.core.constraints import ( + AspectRatioConstraint, + EqualLengthConstraint, + PointOnLineConstraint, + RadiusConstraint, +) +from sketcher.core.entities import Circle, Line + + +def _lines(sketch): + return [ + e + for e in sketch.registry.entities + if isinstance(e, Line) and not e.construction + ] + + +def _circles(sketch): + return [e for e in sketch.registry.entities if isinstance(e, Circle)] + + +def _corner_positions(sketch): + registry = sketch.registry + return [ + (registry.get_point(line.p1_idx).x, registry.get_point(line.p1_idx).y) + for line in _lines(sketch) + ] + + +def _execute(sides=6, star_ratio=None, fixed_radius=None, end=(10.0, 0.0)): + sketch = Sketch() + center = sketch.add_point(0, 0) + cmd = PolygonCommand( + sketch, + center, + end, + sides=sides, + star_ratio=star_ratio, + fixed_radius=fixed_radius, + ) + cmd.execute() + return sketch, center, cmd + + +class TestPolygonVertices: + def test_regular_polygon(self): + vertices = polygon_vertices((0, 0), (10, 0), 4) + expected = [(10, 0), (0, 10), (-10, 0), (0, -10)] + for (x, y), (ex, ey) in zip(vertices, expected): + assert x == pytest.approx(ex, abs=1e-9) + assert y == pytest.approx(ey, abs=1e-9) + + def test_first_vertex_follows_the_drag_point(self): + x, y = polygon_vertices((1, 1), (1, 6), 5)[0] + assert (x, y) == pytest.approx((1, 6)) + + def test_star_alternates_outer_and_inner(self): + vertices = polygon_vertices((0, 0), (10, 0), 5, star_ratio=0.5) + assert len(vertices) == 10 + radii = [math.hypot(x, y) for x, y in vertices] + assert radii[0::2] == pytest.approx([10] * 5) + assert radii[1::2] == pytest.approx([5] * 5) + assert math.degrees(math.atan2(*vertices[1][::-1])) == pytest.approx( + 36 + ) + + +class TestPolygonCommand: + def test_creates_plain_points_and_lines(self): + sketch, _center, _cmd = _execute(sides=6) + lines = _lines(sketch) + assert len(lines) == 6 + assert len(sketch.registry.points) == 1 + 1 + 6 + for a, b in zip(lines, lines[1:] + lines[:1]): + assert a.p2_idx == b.p1_idx + + def test_corners_are_regular(self): + sketch, _center, _cmd = _execute(sides=6, end=(0.0, 10.0)) + corners = _corner_positions(sketch) + assert corners[0] == pytest.approx((0.0, 10.0)) + for x, y in corners: + assert math.hypot(x, y) == pytest.approx(10.0) + + def test_construction_circle_around_the_center(self): + sketch, center, _cmd = _execute(sides=6) + (circle,) = _circles(sketch) + assert circle.construction + assert circle.center_idx == center + + def test_constraints_keep_it_regular(self): + sketch, _center, _cmd = _execute(sides=6) + constraints = sketch.constraints + (equal,) = [ + c for c in constraints if isinstance(c, EqualLengthConstraint) + ] + assert sorted(equal.entity_ids) == sorted( + line.id for line in _lines(sketch) + ) + on_circle = [ + c for c in constraints if isinstance(c, PointOnLineConstraint) + ] + assert len(on_circle) == 5 + + def test_typed_radius_adds_a_radius_constraint(self): + sketch, _center, _cmd = _execute(sides=6, fixed_radius=25.0) + (radius,) = [ + c for c in sketch.constraints if isinstance(c, RadiusConstraint) + ] + assert radius.value == 25.0 + assert math.hypot(*_corner_positions(sketch)[0]) == pytest.approx(25) + + def test_star(self): + sketch, _center, _cmd = _execute(sides=5, star_ratio=0.4) + assert len(_lines(sketch)) == 10 + circles = _circles(sketch) + assert len(circles) == 2 + assert all(c.construction for c in circles) + (ratio,) = [ + c + for c in sketch.constraints + if isinstance(c, AspectRatioConstraint) + ] + assert ratio.ratio == pytest.approx(0.4) + + def test_solver_keeps_the_shape(self): + sketch, _center, _cmd = _execute(sides=7, star_ratio=0.5) + before = _corner_positions(sketch) + assert sketch.solve() + assert _corner_positions(sketch) == pytest.approx(before, abs=1e-6) + + def test_dragging_a_corner_keeps_it_regular(self): + sketch, center, _cmd = _execute(sides=6) + first = _lines(sketch)[0].p1_idx + point = sketch.registry.get_point(first) + point.x, point.y = 0.0, 20.0 + sketch.solve() + c = sketch.registry.get_point(center) + radii = [ + math.hypot(x - c.x, y - c.y) for x, y in _corner_positions(sketch) + ] + assert radii == pytest.approx([radii[0]] * 6, rel=1e-4) + + def test_rejects_fewer_than_three_sides(self): + with pytest.raises(ValueError): + _execute(sides=2) + + def test_undo_removes_everything(self): + sketch, _center, cmd = _execute(sides=6) + cmd.undo() + assert _lines(sketch) == [] + assert _circles(sketch) == [] + assert len(sketch.registry.points) == 2 + + def test_temporary_center_is_taken_over(self): + sketch = Sketch() + registry = sketch.registry + center = registry.add_point(5, 5) + cmd = PolygonCommand( + sketch, center, (15, 5), sides=4, is_center_temp=True + ) + cmd.execute() + assert registry.get_point(center) is not None + cmd.undo() + assert all(p.id != center for p in registry.points) + + +class TestPolygonPreview: + def test_preview_draws_the_polygon(self): + sketch = Sketch() + registry = sketch.registry + state = PolygonCommand.start_preview(registry, 0.0, 0.0, sides=5) + assert isinstance(state, PolygonPreviewState) + PolygonCommand.update_preview(registry, state, 10.0, 0.0) + assert len(state.line_ids) == 5 + assert registry.get_point(state.radius_id).x == pytest.approx(10.0) + + def test_changing_the_shape_rebuilds_the_preview(self): + sketch = Sketch() + registry = sketch.registry + state = PolygonCommand.start_preview(registry, 0.0, 0.0) + PolygonCommand.update_preview(registry, state, 10.0, 0.0) + state.set_shape(registry, sides=5, star_ratio=0.5) + assert len(state.line_ids) == 10 + PolygonCommand.cleanup_preview(registry, state) + assert registry.entities == [] + assert {p.id for p in registry.points} == {0, state.center_id} + + def test_radius_input_scales_the_preview(self): + sketch = Sketch() + registry = sketch.registry + state = PolygonCommand.start_preview(registry, 0.0, 0.0) + PolygonCommand.update_preview(registry, state, 3.0, 4.0) + state.set_radius(registry, 10.0) + p = registry.get_point(state.radius_id) + assert (p.x, p.y) == pytest.approx((6.0, 8.0)) + PolygonCommand.update_preview(registry, state, 1.0, 1.0) + p = registry.get_point(state.radius_id) + assert math.hypot(p.x, p.y) == pytest.approx(10.0) + + def test_dimensions(self): + sketch = Sketch() + registry = sketch.registry + state = PolygonCommand.start_preview(registry, 0.0, 0.0) + PolygonCommand.update_preview(registry, state, 10.0, 0.0) + labels = [d.label for d in state.get_dimensions(registry)] + assert labels[0].startswith("R") + assert labels[1] == "6" diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/ui_gtk/tools/test_polygon_tool.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/ui_gtk/tools/test_polygon_tool.py new file mode 100644 index 000000000..872b3b375 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/ui_gtk/tools/test_polygon_tool.py @@ -0,0 +1,154 @@ +import math +from unittest.mock import MagicMock + +import pytest +from sketcher.core import Sketch +from sketcher.core.constraints import RadiusConstraint +from sketcher.core.entities import Circle, Line +from sketcher.ui_gtk.tools import PolygonTool +from sketcher.ui_gtk.tools.base import SketcherKey + + +@pytest.fixture +def element(): + element = MagicMock() + element.sketch = Sketch() + element.hittester.get_hit_data.return_value = (None, None) + element.hittester.screen_to_model.side_effect = lambda x, y, e: (x, y) + element.canvas.view_transform.get_scale.return_value = (1.0, 1.0) + element.snap_engine.query.return_value = MagicMock( + snapped=False, position=(0.0, 0.0), snap_lines=[] + ) + element.execute_command.side_effect = lambda cmd: cmd.execute() + element.remove_point_if_unused.side_effect = ( + element.sketch.remove_point_if_unused + ) + return element + + +@pytest.fixture +def tool(element, monkeypatch): + tool = PolygonTool(element) + monkeypatch.setattr( + tool, "query_snap_for_creation", lambda el, x, y, exclude: (x, y) + ) + monkeypatch.setattr(tool, "get_snapped_point_id", lambda: None) + return tool + + +def _outline(sketch): + return [ + e + for e in sketch.registry.entities + if isinstance(e, Line) and not e.construction + ] + + +def _type(tool, text): + for char in text: + tool.handle_text_input(char) + + +@pytest.mark.ui +def test_drag_creates_a_hexagon(tool, element): + tool.on_press(0.0, 0.0, 1) + tool.on_hover_motion(10.0, 0.0) + tool.on_release(10.0, 0.0) + assert tool.get_preview_state() is None + lines = _outline(element.sketch) + assert len(lines) == 6 + circles = [ + e for e in element.sketch.registry.entities if isinstance(e, Circle) + ] + assert len(circles) == 1 and circles[0].construction + + +@pytest.mark.ui +def test_two_clicks_create_the_polygon(tool, element): + tool.on_press(0.0, 0.0, 1) + tool.on_release(0.5, 0.0) + assert tool.get_preview_state() is not None + tool.on_hover_motion(0.0, 8.0) + tool.on_press(0.0, 8.0, 1) + assert len(_outline(element.sketch)) == 6 + + +@pytest.mark.ui +def test_ctrl_makes_a_star(tool, element): + tool.on_press(0.0, 0.0, 1) + tool.on_modifier_change(ctrl=True) + state = tool.get_preview_state() + assert state is not None and state.star_ratio == 0.5 + tool.on_hover_motion(10.0, 0.0) + tool.on_release(10.0, 0.0) + assert len(_outline(element.sketch)) == 12 + + +@pytest.mark.ui +def test_typed_values_set_radius_corners_and_star(tool, element): + tool.on_press(0.0, 0.0, 1) + tool.on_release(0.5, 0.0) + tool.on_hover_motion(3.0, 4.0) + _type(tool, "20") + tool.handle_key_event(SketcherKey.TAB) + _type(tool, "5") + tool.handle_key_event(SketcherKey.TAB) + _type(tool, "0.4") + tool.handle_key_event(SketcherKey.RETURN) + + sketch = element.sketch + assert tool.get_preview_state() is None + assert len(_outline(sketch)) == 10 + (radius,) = [ + c for c in sketch.constraints if isinstance(c, RadiusConstraint) + ] + assert radius.value == 20.0 + first = sketch.registry.get_point(_outline(sketch)[0].p1_idx) + assert math.hypot(first.x, first.y) == pytest.approx(20.0) + assert math.atan2(first.y, first.x) == pytest.approx(math.atan2(4, 3)) + + +@pytest.mark.ui +def test_invalid_corner_count_is_ignored(tool, element): + tool.on_press(0.0, 0.0, 1) + tool.on_release(0.5, 0.0) + tool.on_hover_motion(10.0, 0.0) + _type(tool, "10") + tool.handle_key_event(SketcherKey.TAB) + tool.handle_text_input("1") + tool.handle_key_event(SketcherKey.TAB) + state = tool.get_preview_state() + assert state is not None and state.sides == 6 + assert state.locked_radius == 10.0 + + +@pytest.mark.ui +def test_deactivate_removes_the_preview(tool, element): + points_before = len(element.sketch.registry.points) + tool.on_press(0.0, 0.0, 1) + tool.on_hover_motion(10.0, 0.0) + tool.on_deactivate() + assert element.sketch.registry.entities == [] + assert len(element.sketch.registry.points) == points_before + + +@pytest.mark.ui +def test_corner_count_is_remembered(tool, element): + tool.on_press(0.0, 0.0, 1) + tool.on_release(0.5, 0.0) + tool.on_hover_motion(10.0, 0.0) + _type(tool, "10") + tool.handle_key_event(SketcherKey.TAB) + _type(tool, "8") + tool.handle_key_event(SketcherKey.TAB) + tool.on_press(10.0, 0.0, 1) + tool.on_press(30.0, 0.0, 1) + state = tool.get_preview_state() + assert state is not None and state.sides == 8 + + +@pytest.mark.ui +def test_registered_in_the_tool_registry(): + from sketcher.ui_gtk.tools import TOOL_REGISTRY + + assert TOOL_REGISTRY["polygon"] is PolygonTool diff --git a/rayforge/resources/icons/sketch-polygon-symbolic.svg b/rayforge/resources/icons/sketch-polygon-symbolic.svg new file mode 100644 index 000000000..872e7defa --- /dev/null +++ b/rayforge/resources/icons/sketch-polygon-symbolic.svg @@ -0,0 +1,15 @@ + + + + diff --git a/website/docs/features/sketcher/index.md b/website/docs/features/sketcher/index.md index d0808f956..db8f8702e 100644 --- a/website/docs/features/sketcher/index.md +++ b/website/docs/features/sketcher/index.md @@ -95,6 +95,7 @@ Each drawing and modification tool additionally has a two-key shortcut, document | [Ellipse](arc-ellipse.md) | `G+C` | | [Rectangle](rectangle.md) | `G+R` | | [Rounded Rectangle](rectangle.md) | `G+O` | +| [Polygon](polygon.md) | `G+X` | | [Fill Area](fill.md) | `G+F` | | [Text Box](text-box.md) | `G+T` | | [Circular Array](arrays.md) | `G+Y` | @@ -144,6 +145,7 @@ they get in the way. The drawing tools are each documented on their own page: [Path](path.md) (lines and bezier curves), [Arc and Ellipse](arc-ellipse.md), [Rectangle](rectangle.md) (and rounded rectangles), +[Polygon](polygon.md) (and stars), [Fill Areas](fill.md), [Text Box](text-box.md), and [Grid](grid.md). Modifications such as [Offset](offset.md) and [Chamfer and Fillet](chamfer-fillet.md) reshape existing geometry, [Arrays](arrays.md) copies it along a circle or a curve, and diff --git a/website/docs/features/sketcher/polygon.md b/website/docs/features/sketcher/polygon.md new file mode 100644 index 000000000..f6c81d5b4 --- /dev/null +++ b/website/docs/features/sketcher/polygon.md @@ -0,0 +1,36 @@ +--- +description: + "Draw regular polygons and stars in the Rayforge sketcher. They are made of ordinary points and + lines, kept regular by constraints." +--- + +# Polygon and Star + +The **polygon** tool (`G+X`) draws a regular polygon, such as a hexagon for a coaster, or a star. + +## Drawing a Polygon + +Click where the center should be, move the pointer and click again, or press at the center, drag +and release. The pointer sets the first corner, so it sets both the size and the rotation. A new +polygon has 6 corners. + +- Hold `Ctrl` to draw a star instead: it adds an inner corner between every two outer corners, at + half the outer radius. + +While the preview is active, you can type exact values: the status bar shows the `R`, `N` and `Star` +fields. `R` is the radius from the center to the corners, `N` the number of corners (3 to 100), and +`Star` the inner radius as a fraction of the outer one (between 0 and 1). Typing a `Star` value +turns the polygon into a star. Type a value, press `Tab` to move to the next field, and `Enter` to +create the shape; `Esc` cancels the preview. The tool remembers the corner count for the next +polygon. + +## What You Get + +A polygon is not a special object: it consists of ordinary points and lines, so fillet, chamfer, +offset and boolean operations work on it like on any other shape. + +Constraints keep it regular. All edges have equal length, and the corners lie on a construction +circle around the center point; a star has a second construction circle for its inner corners and +keeps the ratio between the two radii. When you typed a radius, a radius constraint fixes it. You +can then drag the center to move the polygon, or drag a corner to resize and rotate it, and it stays +regular. To change the size later, edit the radius constraint or add one to the construction circle. diff --git a/website/sidebars.js b/website/sidebars.js index e3083eb15..0428e60f6 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -91,6 +91,7 @@ module.exports = { 'features/sketcher/path', 'features/sketcher/arc-ellipse', 'features/sketcher/rectangle', + 'features/sketcher/polygon', 'features/sketcher/chamfer-fillet', 'features/sketcher/fill', 'features/sketcher/text-box', From 77f6193c36989bdfb2c5cd92390afd6b21f90999 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Tue, 13 Oct 2026 19:24:49 +0200 Subject: [PATCH 072/104] test(cache): type the byte-budget LruCache tests for pyright --- tests/shared/test_shared_util_cache.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/tests/shared/test_shared_util_cache.py b/tests/shared/test_shared_util_cache.py index b7d8e51f1..dc905745c 100644 --- a/tests/shared/test_shared_util_cache.py +++ b/tests/shared/test_shared_util_cache.py @@ -129,7 +129,9 @@ def test_evicts_least_recently_used_entry(self): assert set(cache.keys()) == {"a", "c"} def test_byte_budget_evicts_least_recently_used(self): - cache = LruCache(max_entries=10, max_bytes=100, sizeof=lambda v: v) + cache = LruCache[str, int]( + max_entries=10, max_bytes=100, sizeof=lambda v: v + ) cache.put("a", 40) cache.put("b", 40) cache.get("a") @@ -138,21 +140,27 @@ def test_byte_budget_evicts_least_recently_used(self): assert cache.nbytes == 80 def test_keeps_newest_entry_even_over_budget(self): - cache = LruCache(max_entries=10, max_bytes=100, sizeof=lambda v: v) + cache = LruCache[str, int]( + max_entries=10, max_bytes=100, sizeof=lambda v: v + ) cache.put("a", 40) cache.put("big", 500) assert list(cache.keys()) == ["big"] assert cache.nbytes == 500 def test_replacing_a_key_updates_the_size(self): - cache = LruCache(max_entries=10, max_bytes=100, sizeof=lambda v: v) + cache = LruCache[str, int]( + max_entries=10, max_bytes=100, sizeof=lambda v: v + ) cache.put("a", 40) cache.put("a", 10) assert len(cache) == 1 assert cache.nbytes == 10 def test_clear_empties_the_cache(self): - cache = LruCache(max_entries=10, max_bytes=100, sizeof=lambda v: v) + cache = LruCache[str, int]( + max_entries=10, max_bytes=100, sizeof=lambda v: v + ) cache.put("a", 40) cache.clear() assert len(cache) == 0 From 87fe0b1d513174c8aad7adad1f8b630a855382ec Mon Sep 17 00:00:00 2001 From: Samuel Abels Date: Wed, 14 Oct 2026 13:08:22 +0000 Subject: [PATCH 073/104] feat(frame): let the user choose whether the frame includes overscan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The machine frame traces the whole job artifact rect, which includes the engraving overscan margin: the overscan transformer extends raster lines with zero-power lead-in/lead-out moves for acceleration room, so the framed outline overshoots the artwork, mainly in X (issue #563). The frame button now opens a popover (like the one for equidistant tabs) with an 'Include Overscan Margin' switch. When the switch is off, the frame is built from the envelope the laser actually burns — Ops.burning_rect(), added to raygeo — instead of the full machine path. On raygeo releases without burning_rect the frame keeps the previous behavior. The travel warning keeps using the traced frame; catalogs gained the two new strings. --- rayforge/locale/de/LC_MESSAGES/rayforge.po | 9 ++ rayforge/locale/en/LC_MESSAGES/rayforge.po | 9 ++ rayforge/locale/es/LC_MESSAGES/rayforge.po | 9 ++ rayforge/locale/fr/LC_MESSAGES/rayforge.po | 9 ++ rayforge/locale/hi/LC_MESSAGES/rayforge.po | 9 ++ rayforge/locale/pt/LC_MESSAGES/rayforge.po | 9 ++ rayforge/locale/rayforge.pot | 9 ++ rayforge/locale/uk/LC_MESSAGES/rayforge.po | 9 ++ rayforge/locale/zh_CN/LC_MESSAGES/rayforge.po | 9 ++ rayforge/machine/cmd.py | 44 ++++++- rayforge/ui_gtk/doceditor/frame_popover.py | 56 +++++++++ rayforge/ui_gtk/mainwindow.py | 17 ++- tests/machine/test_machine_cmd.py | 109 +++++++++++++++++- 13 files changed, 302 insertions(+), 5 deletions(-) create mode 100644 rayforge/ui_gtk/doceditor/frame_popover.py diff --git a/rayforge/locale/de/LC_MESSAGES/rayforge.po b/rayforge/locale/de/LC_MESSAGES/rayforge.po index 994cf0f3e..a04568b3e 100644 --- a/rayforge/locale/de/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/de/LC_MESSAGES/rayforge.po @@ -10553,3 +10553,12 @@ msgid "" "How the Z axis finds its reference: a home switch, " "or the auto-focus probe" msgstr "Wie die Z-Achse ihre Referenz findet: über einen Referenzschalter oder den Autofokus-Taster" + +#: rayforge/ui_gtk/doceditor/frame_popover.py +msgid "Include Overscan Margin" +msgstr "Overscan-Marge einschließen" + +#: rayforge/ui_gtk/doceditor/frame_popover.py +msgid "" +"Frame the full machine path, including the extra engraving travel" +msgstr "Den vollständigen Maschinenpfad umrahmen, einschließlich des zusätzlichen Gravierwegs" diff --git a/rayforge/locale/en/LC_MESSAGES/rayforge.po b/rayforge/locale/en/LC_MESSAGES/rayforge.po index 911e21841..fcdf7e9b3 100644 --- a/rayforge/locale/en/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/en/LC_MESSAGES/rayforge.po @@ -9911,3 +9911,12 @@ msgid "" "How the Z axis finds its reference: a home switch, " "or the auto-focus probe" msgstr "" + +#: rayforge/ui_gtk/doceditor/frame_popover.py +msgid "Include Overscan Margin" +msgstr "" + +#: rayforge/ui_gtk/doceditor/frame_popover.py +msgid "" +"Frame the full machine path, including the extra engraving travel" +msgstr "" diff --git a/rayforge/locale/es/LC_MESSAGES/rayforge.po b/rayforge/locale/es/LC_MESSAGES/rayforge.po index 176508446..47ad05aff 100644 --- a/rayforge/locale/es/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/es/LC_MESSAGES/rayforge.po @@ -10527,3 +10527,12 @@ msgid "" "How the Z axis finds its reference: a home switch, " "or the auto-focus probe" msgstr "Cómo encuentra el eje Z su referencia: un interruptor de origen o la sonda de autoenfoque" + +#: rayforge/ui_gtk/doceditor/frame_popover.py +msgid "Include Overscan Margin" +msgstr "Incluir el margen de overscan" + +#: rayforge/ui_gtk/doceditor/frame_popover.py +msgid "" +"Frame the full machine path, including the extra engraving travel" +msgstr "Enmarca la ruta completa de la máquina, incluido el recorrido adicional de grabado" diff --git a/rayforge/locale/fr/LC_MESSAGES/rayforge.po b/rayforge/locale/fr/LC_MESSAGES/rayforge.po index bb757befd..a9b9cf80b 100644 --- a/rayforge/locale/fr/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/fr/LC_MESSAGES/rayforge.po @@ -10574,3 +10574,12 @@ msgid "" "How the Z axis finds its reference: a home switch, " "or the auto-focus probe" msgstr "Comment l'axe Z trouve sa référence : un interrupteur d'origine ou le palpeur d'autofocus" + +#: rayforge/ui_gtk/doceditor/frame_popover.py +msgid "Include Overscan Margin" +msgstr "Inclure la marge d'overscan" + +#: rayforge/ui_gtk/doceditor/frame_popover.py +msgid "" +"Frame the full machine path, including the extra engraving travel" +msgstr "Encadre le trajet complet de la machine, y compris le parcours de gravure supplémentaire" diff --git a/rayforge/locale/hi/LC_MESSAGES/rayforge.po b/rayforge/locale/hi/LC_MESSAGES/rayforge.po index 3a25b2c93..2826cc548 100644 --- a/rayforge/locale/hi/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/hi/LC_MESSAGES/rayforge.po @@ -10233,3 +10233,12 @@ msgid "" "How the Z axis finds its reference: a home switch, " "or the auto-focus probe" msgstr "Z अक्ष अपना संदर्भ कैसे ढूँढता है: होम स्विच से, या ऑटो-फ़ोकस प्रोब से" + +#: rayforge/ui_gtk/doceditor/frame_popover.py +msgid "Include Overscan Margin" +msgstr "ओवरस्कैन मार्जिन शामिल करें" + +#: rayforge/ui_gtk/doceditor/frame_popover.py +msgid "" +"Frame the full machine path, including the extra engraving travel" +msgstr "अतिरिक्त उत्कीर्णन यात्रा सहित मशीन के पूरे पथ का फ़्रेम बनाएं" diff --git a/rayforge/locale/pt/LC_MESSAGES/rayforge.po b/rayforge/locale/pt/LC_MESSAGES/rayforge.po index e90f34136..032cd596f 100644 --- a/rayforge/locale/pt/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/pt/LC_MESSAGES/rayforge.po @@ -10479,3 +10479,12 @@ msgid "" "How the Z axis finds its reference: a home switch, " "or the auto-focus probe" msgstr "Como o eixo Z encontra sua referência: um interruptor de referência ou a sonda de foco automático" + +#: rayforge/ui_gtk/doceditor/frame_popover.py +msgid "Include Overscan Margin" +msgstr "Incluir a margem de overscan" + +#: rayforge/ui_gtk/doceditor/frame_popover.py +msgid "" +"Frame the full machine path, including the extra engraving travel" +msgstr "Moldura o caminho completo da máquina, incluindo o trajeto extra de gravação" diff --git a/rayforge/locale/rayforge.pot b/rayforge/locale/rayforge.pot index 086802990..fa1023583 100644 --- a/rayforge/locale/rayforge.pot +++ b/rayforge/locale/rayforge.pot @@ -9912,3 +9912,12 @@ msgid "" "How the Z axis finds its reference: a home switch, " "or the auto-focus probe" msgstr "" + +#: rayforge/ui_gtk/doceditor/frame_popover.py +msgid "Include Overscan Margin" +msgstr "" + +#: rayforge/ui_gtk/doceditor/frame_popover.py +msgid "" +"Frame the full machine path, including the extra engraving travel" +msgstr "" diff --git a/rayforge/locale/uk/LC_MESSAGES/rayforge.po b/rayforge/locale/uk/LC_MESSAGES/rayforge.po index 4accb7547..755be5277 100644 --- a/rayforge/locale/uk/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/uk/LC_MESSAGES/rayforge.po @@ -10439,3 +10439,12 @@ msgid "" "How the Z axis finds its reference: a home switch, " "or the auto-focus probe" msgstr "Як вісь Z знаходить свою точку відліку: за кінцевим вимикачем або зондом автофокусу" + +#: rayforge/ui_gtk/doceditor/frame_popover.py +msgid "Include Overscan Margin" +msgstr "Урахувати межі overscan" + +#: rayforge/ui_gtk/doceditor/frame_popover.py +msgid "" +"Frame the full machine path, including the extra engraving travel" +msgstr "Окреслює повний шлях верстата, включно з додатковим ходом гравіювання" diff --git a/rayforge/locale/zh_CN/LC_MESSAGES/rayforge.po b/rayforge/locale/zh_CN/LC_MESSAGES/rayforge.po index 61827cbf4..711fd93db 100644 --- a/rayforge/locale/zh_CN/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/zh_CN/LC_MESSAGES/rayforge.po @@ -10040,3 +10040,12 @@ msgid "" "How the Z axis finds its reference: a home switch, " "or the auto-focus probe" msgstr "Z轴如何找到参考位置:通过原点开关,或通过自动对焦探针" + +#: rayforge/ui_gtk/doceditor/frame_popover.py +msgid "Include Overscan Margin" +msgstr "包含过冲边距" + +#: rayforge/ui_gtk/doceditor/frame_popover.py +msgid "" +"Frame the full machine path, including the extra engraving travel" +msgstr "沿整个机器路径走边框,包括额外的雕刻行程" diff --git a/rayforge/machine/cmd.py b/rayforge/machine/cmd.py index 420a19056..21efd3a5d 100644 --- a/rayforge/machine/cmd.py +++ b/rayforge/machine/cmd.py @@ -2,6 +2,7 @@ import logging from collections.abc import Awaitable, Callable, Coroutine +from functools import partial from gettext import gettext as _ from typing import TYPE_CHECKING @@ -209,6 +210,7 @@ async def _run_frame_action( artifact: JobArtifact, machine: Machine, on_progress: Callable[[dict], None] | None, + include_overscan: bool = False, ): """The specific machine action for a framing job. @@ -216,6 +218,11 @@ async def _run_frame_action( transforms a job gets — and hands the corners to the driver, which frames in whatever way fits the controller (see Driver.frame()). + + With *include_overscan* the frame traces the whole machine + path; otherwise it is built from the envelope the laser + actually burns, so raster overscan margins stay outside the + framed area (issue #563). """ if not isinstance(artifact, JobArtifact): raise TypeError("_run_frame_action received a non-JobArtifact") @@ -233,7 +240,8 @@ async def _run_frame_action( # A moves-only trace carrying the outline through the same # transforms a job gets. - frame_ops = _build_frame_trace(artifact.ops.rect(), repeat_count) + frame_rect = _job_frame_rect(artifact.ops, include_overscan) + frame_ops = _build_frame_trace(frame_rect, repeat_count) # Apply the active layer's rotary mapping so the frame drives # the rotary axis instead of the physical Y axis (issue #356). @@ -281,7 +289,7 @@ async def _run_frame_action( ) corners = _frame_trace_corners( - frame_ops, len(_frame_rect_corners(artifact.ops.rect())) + frame_ops, len(_frame_rect_corners(frame_rect)) ) async def execute( @@ -394,15 +402,23 @@ async def frame_job( self, machine: Machine, on_progress: Callable[[dict], None] | None = None, + include_overscan: bool = False, ): """ Asynchronously generates ops and runs a framing job. This is an awaitable coroutine. + + When *include_overscan* is set, the frame traces the full + machine path including the overscan acceleration margins; + otherwise it traces the area the laser actually burns. """ try: await self._start_job( machine, - final_job_action=self._run_frame_action, + final_job_action=partial( + self._run_frame_action, + include_overscan=include_overscan, + ), on_progress=on_progress, ) except JobAlreadyRunningError as e: @@ -669,6 +685,28 @@ def _apply_frame_rotary_mapping( return module +def _job_frame_rect( + ops: Ops, include_overscan: bool +) -> tuple[float, float, float, float]: + """The job-area rect the frame outline is built from. + + Overscan extends raster lines with zero-power lead-in and lead-out + moves, so the burning envelope excludes them while the plain ops + rect includes them. The plain rect is also used when raygeo has no + burning envelope (older releases) or when nothing burns at all, so + an empty job still frames its travel area. + """ + if include_overscan: + return ops.rect() + burning_rect = getattr(ops, "burning_rect", None) + if burning_rect is None: + return ops.rect() + rect = burning_rect() + if rect == (0.0, 0.0, 0.0, 0.0): + return ops.rect() + return rect + + def _frame_rect_corners( rect: tuple[float, float, float, float], ) -> list[tuple[float, float]]: diff --git a/rayforge/ui_gtk/doceditor/frame_popover.py b/rayforge/ui_gtk/doceditor/frame_popover.py new file mode 100644 index 000000000..d1718b1e0 --- /dev/null +++ b/rayforge/ui_gtk/doceditor/frame_popover.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import logging +from collections.abc import Callable +from gettext import gettext as _ + +from gi.repository import Adw, Gtk + +logger = logging.getLogger(__name__) + + +class FramePopover(Gtk.Popover): + """ + Lets the user choose the extent of the frame before it runs. + + The engraving overscan extends raster lines with zero-power travel + for acceleration room. Framing that path overshoots the artwork + (issue #563); this popover lets the user frame the burned area + only, or keep the full machine path including the overscan. + """ + + def __init__(self, on_frame: Callable[[bool], None]): + super().__init__() + self.on_frame = on_frame + + content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) + content_box.set_margin_top(12) + content_box.set_margin_bottom(12) + content_box.set_margin_start(12) + content_box.set_margin_end(12) + self.set_child(content_box) + + rows_container = Gtk.ListBox() + rows_container.set_selection_mode(Gtk.SelectionMode.NONE) + rows_container.add_css_class("boxed-list") + content_box.append(rows_container) + + self.overscan_row = Adw.SwitchRow( + title=_("Include Overscan Margin"), + subtitle=_( + "Frame the full machine path, including the extra " + "engraving travel" + ), + active=False, + ) + rows_container.append(self.overscan_row) + + frame_button = Gtk.Button(label=_("Frame")) + frame_button.add_css_class("suggested-action") + frame_button.add_css_class("pill") + frame_button.connect("clicked", self._on_frame_clicked) + content_box.append(frame_button) + + def _on_frame_clicked(self, button): + self.popdown() + self.on_frame(self.overscan_row.get_active()) diff --git a/rayforge/ui_gtk/mainwindow.py b/rayforge/ui_gtk/mainwindow.py index af9215482..3d07e218f 100644 --- a/rayforge/ui_gtk/mainwindow.py +++ b/rayforge/ui_gtk/mainwindow.py @@ -18,6 +18,7 @@ from ..core.registration import call_registration_hooks from ..core.undo import Command, HistoryManager from ..doceditor.editor import DocEditor +from ..doceditor.frame_popover import FramePopover from ..machine.cmd import MachineCmd from ..machine.driver.driver import DeviceState, DeviceStatus from ..machine.driver.dummy import NoDeviceDriver @@ -2526,6 +2527,18 @@ def on_frame_clicked(self, action, param): if not config.machine: return + # Let the user choose the frame extent before the job starts. + popover = FramePopover(on_frame=self._start_frame_job) + popover.connect("closed", lambda p: p.unparent()) + popover.set_parent(self.toolbar.frame_button) + popover.popup() + + def _start_frame_job(self, include_overscan: bool): + config = get_context().config + machine = config.machine + if not machine: + return + # Disable focus mode when framing focus_action = self.action_manager.get_action("toggle-focus") focus_state = focus_action.get_state() @@ -2534,7 +2547,9 @@ def on_frame_clicked(self, action, param): # Get the coroutine object for the framing job job_coro = self.machine_cmd.frame_job( - config.machine, on_progress=self._on_job_progress_updated + machine, + on_progress=self._on_job_progress_updated, + include_overscan=include_overscan, ) # Run the job using the helper self._run_machine_job(job_coro) diff --git a/tests/machine/test_machine_cmd.py b/tests/machine/test_machine_cmd.py index 3433eb610..ecd6c5e42 100644 --- a/tests/machine/test_machine_cmd.py +++ b/tests/machine/test_machine_cmd.py @@ -1,6 +1,6 @@ import asyncio from functools import partial -from unittest.mock import MagicMock, PropertyMock +from unittest.mock import AsyncMock, MagicMock, PropertyMock import pytest import pytest_asyncio @@ -16,6 +16,7 @@ _build_frame_trace, _frame_rect_corners, _frame_trace_corners, + _job_frame_rect, ) from rayforge.machine.driver.driver import ( FRAME_LAYER_UID, @@ -764,6 +765,112 @@ def test_trace_corners_read_back_in_order(self): ] +class TestFrameOverscan: + """The frame traces the burned area, not the overscan margin.""" + + @pytest.fixture + def overscan_artifact(self, machine): + """A job artifact whose ops carry overscan-style lead-out. + + The powered content spans x 10..30; a zero-power move extends + the path to x 35, the way the overscan transformer does. + """ + ops = Ops() + ops.move_to(10, 10, 0) + ops.set_power(0.8) + ops.line_to(30, 10, 0) + ops.set_power(0.0) + ops.move_to(30, 10, 0) + ops.line_to(35, 10, 0) + encoded = machine.driver.get_encoder().encode(ops, machine, None) + return JobArtifact( + ops=ops, + distance=ops.distance(), + generation_id=1, + encoded_output=encoded, + ) + + async def _run_and_capture_corners( + self, machine_cmd, machine, artifact, mocker, include_overscan + ): + frame_mock = mocker.patch.object( + machine.driver, "frame", new_callable=AsyncMock + ) + await machine_cmd._run_frame_action( + artifact, + machine, + on_progress=None, + include_overscan=include_overscan, + ) + await asyncio.sleep(0) + assert frame_mock.await_count == 1 + return frame_mock.await_args.args[0] + + @pytest.mark.asyncio + @pytest.mark.skipif( + not hasattr(Ops, "burning_rect"), + reason="raygeo < 1.65 has no burning_rect; the frame falls " + "back to the plain rect", + ) + async def test_frame_excludes_overscan_by_default( + self, machine_cmd, doc_editor, machine, overscan_artifact, mocker + ): + corners = await self._run_and_capture_corners( + machine_cmd, + machine, + overscan_artifact, + mocker, + include_overscan=False, + ) + + xs = [corner[0] for corner in corners] + assert min(xs) == pytest.approx(10.0) + assert max(xs) == pytest.approx(30.0) + + @pytest.mark.asyncio + async def test_frame_includes_overscan_when_requested( + self, machine_cmd, doc_editor, machine, overscan_artifact, mocker + ): + corners = await self._run_and_capture_corners( + machine_cmd, + machine, + overscan_artifact, + mocker, + include_overscan=True, + ) + + xs = [corner[0] for corner in corners] + assert min(xs) == pytest.approx(10.0) + assert max(xs) == pytest.approx(35.0) + + def test_job_frame_rect_without_burning_envelope(self): + ops = MagicMock(spec=["rect"]) + ops.rect.return_value = (0.0, 0.0, 50.0, 40.0) + + assert _job_frame_rect(ops, include_overscan=False) == ( + 0.0, + 0.0, + 50.0, + 40.0, + ) + ops.rect.assert_called_with() + + def test_job_frame_rect_when_nothing_burns(self, simple_ops): + """Unpowered ops burn nothing; both modes use the plain rect.""" + assert _job_frame_rect(simple_ops, include_overscan=False) == ( + 10.0, + 10.0, + 20.0, + 20.0, + ) + assert _job_frame_rect(simple_ops, include_overscan=True) == ( + 10.0, + 10.0, + 20.0, + 20.0, + ) + + class TestDriverFrameDefault: """Driver.frame() falls back to an encoded job run.""" From 4144ebc1c4e2f47a7744b522b6cf59e29abcd9ec Mon Sep 17 00:00:00 2001 From: Samuel Abels Date: Wed, 14 Oct 2026 20:33:32 +0000 Subject: [PATCH 074/104] feat(frame): use raygeo 1.65.0 powered_rect, drop the fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebase the frame-overscan change onto the released raygeo: Ops.powered_rect() (raygeo#61, released as 1.65.0) is now a hard requirement and the only envelope source — the getattr fallback for older raygeo releases and the skip-guarded test are gone. Bumps the pin to ==1.65.0 in pixi.toml, requirements.txt and debian/requirements-bundle.txt, and re-locks pixi.lock (raygeo-only change). --- debian/requirements-bundle.txt | 2 +- pixi.lock | 90 +++++++++++++++---------------- pixi.toml | 2 +- rayforge/machine/cmd.py | 12 +---- requirements.txt | 2 +- tests/machine/test_machine_cmd.py | 24 +-------- 6 files changed, 52 insertions(+), 80 deletions(-) diff --git a/debian/requirements-bundle.txt b/debian/requirements-bundle.txt index 382501926..da271cdd5 100644 --- a/debian/requirements-bundle.txt +++ b/debian/requirements-bundle.txt @@ -1,7 +1,7 @@ asyncudp==0.11.0 pyvips==3.2.0 raydriver==0.3.0 -raygeo==1.64.0 +raygeo==1.65.0 ruida-pa==0.22.0 trimesh==5.1.1 vtracer==0.6.15 \ No newline at end of file diff --git a/pixi.lock b/pixi.lock index 797aa3f7b..dba89cd50 100644 --- a/pixi.lock +++ b/pixi.lock @@ -243,7 +243,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/13/d4/1152d1c7ab42d8b908be64fd200ddc870dc9d4925e951198702084aa1a7d/linkify_it_py-2.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/17/fe/77f4a24264e728fd95542e53f026a91249b51d1611087b8c82d3a033ea97/asyncudp-0.11.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/18/42/a945f65cc61c739ec80f4112c4b78ed1791f25d33f45f19389c9c9e247e2/pypdf-6.20.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/34/cf/ed6a25ccfc7808be6bd36513ba1f66c7a67bd80de44d30847e327fb80971/raygeo-1.64.0.tar.gz - pypi: https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3f/99/2e119d541d596daea39643eb9312b47c7847383951300f889166938035b1/rpyc-6.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/41/51/8041c2e70649e5b7f2a0aedbbbd0609ac099cfaa0cbde2014279c9c05756/cairosvg-2.9.1-py3-none-any.whl @@ -277,6 +276,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/ae/d4/22cbe288c0d5cef7620503be92c0098d82220353fc7e188034a19c517240/websockets-17.2-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b4/ac/62550bb42cb177a37ff86638670737404dde9417762770a309ddee84fc52/raygeo-1.65.0.tar.gz - pypi: https://files.pythonhosted.org/packages/b5/55/fdd01894807d20fb6181f13590ae247585b10e909b42e6ada833379fe520/plumbum-2.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bd/59/6b1daa3b94de8970e2a2787ba73616c2d0675d2f948ef4cad8bef7f21bc6/cssselect2-0.10.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/be/02/bce7a1d70e9d0f6ea8623c16386138c251b5855af6cd3a11433d3fba4e66/semver-3.1.0-py3-none-any.whl @@ -490,7 +490,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/77/c6/040cbc72480d789a5f40d63fb484d3106554c4dfa2d2b70ad5022057750f/webencodings-0.6.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/85/11/044d1ae1b4ec0d7af88ee5bc91e081be1022533032b906a7bdabdbb60977/pyproject_hooks-1.3.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/8b/0a/2368a687f882e13ed5e94ab39fd7f24cb266b1c099df1c0601662339feb7/raygeo-1.64.0-cp311-abi3-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/8d/15/1633010b26e88e872c93b67c0b6c5e174fb74cb6fb5c1472b4d51d4a8f22/platformdirs-4.13.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8f/f6/fb68b8c08f864ecee8f7f7ee2f1203ed5cba2335215de69e4f621eb47db5/trimesh-5.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/90/11/0e6f11117525ff0eec40ebac3d313376f102df93ca44ad9e893ee85e4f89/pycparser-3.11-py3-none-any.whl @@ -525,6 +524,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/fa/48/f36d233ce749d1bb880cb5e6f8bb171dc2effabc7435c31da604d831b3ec/gitpython-3.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/fd/43/7c4f803e73d12c9e01c65865964d08ca7e5fced779b2a2692b64c5ec6ce5/raygeo-1.65.0-cp311-abi3-macosx_11_0_arm64.whl build: channels: - url: https://conda.anaconda.org/conda-forge/ @@ -920,7 +920,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/18/42/a945f65cc61c739ec80f4112c4b78ed1791f25d33f45f19389c9c9e247e2/pypdf-6.20.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2c/3c/4a01195d93420cad1175bedad13a515dc8a56f95a6e39789b92e582682f5/ruff-0.17.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/34/cf/ed6a25ccfc7808be6bd36513ba1f66c7a67bd80de44d30847e327fb80971/raygeo-1.64.0.tar.gz - pypi: https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3f/99/2e119d541d596daea39643eb9312b47c7847383951300f889166938035b1/rpyc-6.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/41/51/8041c2e70649e5b7f2a0aedbbbd0609ac099cfaa0cbde2014279c9c05756/cairosvg-2.9.1-py3-none-any.whl @@ -962,6 +961,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/ae/d4/22cbe288c0d5cef7620503be92c0098d82220353fc7e188034a19c517240/websockets-17.2-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b4/ac/62550bb42cb177a37ff86638670737404dde9417762770a309ddee84fc52/raygeo-1.65.0.tar.gz - pypi: https://files.pythonhosted.org/packages/b5/55/fdd01894807d20fb6181f13590ae247585b10e909b42e6ada833379fe520/plumbum-2.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b5/d4/8e58a7221419bf8cb194f362ab26abf504c983135975e4c785fac0852886/flake8-7.4.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bd/59/6b1daa3b94de8970e2a2787ba73616c2d0675d2f948ef4cad8bef7f21bc6/cssselect2-0.10.1-py3-none-any.whl @@ -1200,7 +1200,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/77/c6/040cbc72480d789a5f40d63fb484d3106554c4dfa2d2b70ad5022057750f/webencodings-0.6.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/85/11/044d1ae1b4ec0d7af88ee5bc91e081be1022533032b906a7bdabdbb60977/pyproject_hooks-1.3.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/8b/0a/2368a687f882e13ed5e94ab39fd7f24cb266b1c099df1c0601662339feb7/raygeo-1.64.0-cp311-abi3-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/8c/be/bf49399ad2e788121f595903a56447d4b778d67acbb01ecfc1c6d5cf91f6/pygobject_stubs-2.17.0.tar.gz - pypi: https://files.pythonhosted.org/packages/8d/15/1633010b26e88e872c93b67c0b6c5e174fb74cb6fb5c1472b4d51d4a8f22/platformdirs-4.13.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8f/f6/fb68b8c08f864ecee8f7f7ee2f1203ed5cba2335215de69e4f621eb47db5/trimesh-5.1.1-py3-none-any.whl @@ -1244,6 +1243,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/fa/70/fffc9613501877c0a10a1ef73a165e6ded3e53f6e7502227fef56d973933/identify-2.6.20-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/fd/43/7c4f803e73d12c9e01c65865964d08ca7e5fced779b2a2692b64c5ec6ce5/raygeo-1.65.0-cp311-abi3-macosx_11_0_arm64.whl video: channels: - url: https://conda.anaconda.org/conda-forge/ @@ -9494,7 +9494,7 @@ packages: name: rayforge requires_dist: - raydriver==0.3.0 - - raygeo==1.64.0 + - raygeo==1.65.0 - aiohttp==3.14.5 - asyncudp==0.11.0 - blinker==1.9.0 @@ -9688,26 +9688,6 @@ packages: requires_dist: - tomli ; python_full_version <= '3.11' and extra == 'toml' requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/34/cf/ed6a25ccfc7808be6bd36513ba1f66c7a67bd80de44d30847e327fb80971/raygeo-1.64.0.tar.gz - name: raygeo - version: 1.64.0 - sha256: f5f7f3f4e02c410cbc6cbe4cff76848a8abbe0662fc37af0a31b3a634a14f1ee - requires_dist: - - msgpack - - numpy>=1.20.0 - - pycairo ; extra == 'test' - - pytest ; extra == 'test' - - pytest-mock ; extra == 'test' - - streamlit ; extra == 'test' - - streamlit ; extra == 'visual' - - matplotlib ; extra == 'visual' - - matplotlib ; extra == 'docs' - - mdformat ; extra == 'docs' - - mdformat-frontmatter ; extra == 'docs' - - mdformat-tables ; extra == 'docs' - - pillow ; extra == 'docs' - - matplotlib ; extra == 'cli' - requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl name: pyparsing version: 3.3.3 @@ -9913,26 +9893,6 @@ packages: version: 1.3.3 sha256: 5fc53fdac9f7bd63fbcdc868fb5f90b4784d78a53a3d3388cd738b807441a20b requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/8b/0a/2368a687f882e13ed5e94ab39fd7f24cb266b1c099df1c0601662339feb7/raygeo-1.64.0-cp311-abi3-macosx_11_0_arm64.whl - name: raygeo - version: 1.64.0 - sha256: a5d79525221147db112a159a314011ea4eea53b61ae18ae496decedf729ab477 - requires_dist: - - msgpack - - numpy>=1.20.0 - - matplotlib ; extra == 'cli' - - matplotlib ; extra == 'docs' - - mdformat ; extra == 'docs' - - mdformat-frontmatter ; extra == 'docs' - - mdformat-tables ; extra == 'docs' - - pillow ; extra == 'docs' - - pycairo ; extra == 'test' - - pytest ; extra == 'test' - - pytest-mock ; extra == 'test' - - streamlit ; extra == 'test' - - streamlit ; extra == 'visual' - - matplotlib ; extra == 'visual' - requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/8c/be/bf49399ad2e788121f595903a56447d4b778d67acbb01ecfc1c6d5cf91f6/pygobject_stubs-2.17.0.tar.gz name: pygobject-stubs version: 2.17.0 @@ -10207,6 +10167,26 @@ packages: - pytest-timeout ; extra == 'testing' - requests ; extra == 'testing' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/b4/ac/62550bb42cb177a37ff86638670737404dde9417762770a309ddee84fc52/raygeo-1.65.0.tar.gz + name: raygeo + version: 1.65.0 + sha256: 1c893c9f6d27f042d947ec1b62798e8457ef061a6f6d9997d5ec5a8fc5a0a4e0 + requires_dist: + - msgpack + - numpy>=1.20.0 + - pycairo ; extra == 'test' + - pytest ; extra == 'test' + - pytest-mock ; extra == 'test' + - streamlit ; extra == 'test' + - streamlit ; extra == 'visual' + - matplotlib ; extra == 'visual' + - matplotlib ; extra == 'docs' + - mdformat ; extra == 'docs' + - mdformat-frontmatter ; extra == 'docs' + - mdformat-tables ; extra == 'docs' + - pillow ; extra == 'docs' + - matplotlib ; extra == 'cli' + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/b5/55/fdd01894807d20fb6181f13590ae247585b10e909b42e6ada833379fe520/plumbum-2.0.2-py3-none-any.whl name: plumbum version: 2.0.2 @@ -10575,3 +10555,23 @@ packages: - tree-sitter-yaml>=0.6.0 ; python_full_version >= '3.10' and extra == 'syntax' - typing-extensions>=4.4.0,<5.0.0 requires_python: '>=3.9,<4.0' +- pypi: https://files.pythonhosted.org/packages/fd/43/7c4f803e73d12c9e01c65865964d08ca7e5fced779b2a2692b64c5ec6ce5/raygeo-1.65.0-cp311-abi3-macosx_11_0_arm64.whl + name: raygeo + version: 1.65.0 + sha256: a6d6bbc755c0963849d825ac2936da1fe4ea6f666e87e6c8ca450a612b9180ff + requires_dist: + - msgpack + - numpy>=1.20.0 + - matplotlib ; extra == 'cli' + - matplotlib ; extra == 'docs' + - mdformat ; extra == 'docs' + - mdformat-frontmatter ; extra == 'docs' + - mdformat-tables ; extra == 'docs' + - pillow ; extra == 'docs' + - pycairo ; extra == 'test' + - pytest ; extra == 'test' + - pytest-mock ; extra == 'test' + - streamlit ; extra == 'test' + - streamlit ; extra == 'visual' + - matplotlib ; extra == 'visual' + requires_python: '>=3.10' diff --git a/pixi.toml b/pixi.toml index 325f44f42..e942d8266 100644 --- a/pixi.toml +++ b/pixi.toml @@ -38,7 +38,7 @@ rust = "==1.99.0" patchelf = "==0.19.2" [feature.app.pypi-dependencies] -raygeo = "==1.64.0" +raygeo = "==1.65.0" raydriver = "==0.3.0" rayforge = { editable = true, path = "." } ruida-pa = "==0.22.0" diff --git a/rayforge/machine/cmd.py b/rayforge/machine/cmd.py index 21efd3a5d..84bc523fc 100644 --- a/rayforge/machine/cmd.py +++ b/rayforge/machine/cmd.py @@ -692,19 +692,11 @@ def _job_frame_rect( Overscan extends raster lines with zero-power lead-in and lead-out moves, so the burning envelope excludes them while the plain ops - rect includes them. The plain rect is also used when raygeo has no - burning envelope (older releases) or when nothing burns at all, so - an empty job still frames its travel area. + rect includes them. """ if include_overscan: return ops.rect() - burning_rect = getattr(ops, "burning_rect", None) - if burning_rect is None: - return ops.rect() - rect = burning_rect() - if rect == (0.0, 0.0, 0.0, 0.0): - return ops.rect() - return rect + return ops.powered_rect() def _frame_rect_corners( diff --git a/requirements.txt b/requirements.txt index 6ed93615a..a07ba38e4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ raydriver==0.3.0 -raygeo==1.64.0 +raygeo==1.65.0 aiohttp==3.14.5 asyncudp==0.11.0 blinker==1.9.0 diff --git a/tests/machine/test_machine_cmd.py b/tests/machine/test_machine_cmd.py index ecd6c5e42..4b962e082 100644 --- a/tests/machine/test_machine_cmd.py +++ b/tests/machine/test_machine_cmd.py @@ -807,11 +807,6 @@ async def _run_and_capture_corners( return frame_mock.await_args.args[0] @pytest.mark.asyncio - @pytest.mark.skipif( - not hasattr(Ops, "burning_rect"), - reason="raygeo < 1.65 has no burning_rect; the frame falls " - "back to the plain rect", - ) async def test_frame_excludes_overscan_by_default( self, machine_cmd, doc_editor, machine, overscan_artifact, mocker ): @@ -843,11 +838,11 @@ async def test_frame_includes_overscan_when_requested( assert min(xs) == pytest.approx(10.0) assert max(xs) == pytest.approx(35.0) - def test_job_frame_rect_without_burning_envelope(self): + def test_job_frame_rect_includes_full_rect_when_requested(self): ops = MagicMock(spec=["rect"]) ops.rect.return_value = (0.0, 0.0, 50.0, 40.0) - assert _job_frame_rect(ops, include_overscan=False) == ( + assert _job_frame_rect(ops, include_overscan=True) == ( 0.0, 0.0, 50.0, @@ -855,21 +850,6 @@ def test_job_frame_rect_without_burning_envelope(self): ) ops.rect.assert_called_with() - def test_job_frame_rect_when_nothing_burns(self, simple_ops): - """Unpowered ops burn nothing; both modes use the plain rect.""" - assert _job_frame_rect(simple_ops, include_overscan=False) == ( - 10.0, - 10.0, - 20.0, - 20.0, - ) - assert _job_frame_rect(simple_ops, include_overscan=True) == ( - 10.0, - 10.0, - 20.0, - 20.0, - ) - class TestDriverFrameDefault: """Driver.frame() falls back to an encoded job run.""" From 4d82acfa525b9d824b5ec633b8037b2f08aa03e4 Mon Sep 17 00:00:00 2001 From: Samuel Abels Date: Wed, 14 Oct 2026 20:43:52 +0000 Subject: [PATCH 075/104] fix(ui): import the frame popover from the ui doceditor package ..doceditor from mainwindow resolves to the controller package rayforge.doceditor, where DocEditor lives; the popover widget is a Gtk class in rayforge.ui_gtk.doceditor, so the relative import needs a single dot (as the toolbar's AddTabsPopover import does). --- rayforge/ui_gtk/mainwindow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rayforge/ui_gtk/mainwindow.py b/rayforge/ui_gtk/mainwindow.py index 3d07e218f..dad4a3aa4 100644 --- a/rayforge/ui_gtk/mainwindow.py +++ b/rayforge/ui_gtk/mainwindow.py @@ -18,7 +18,7 @@ from ..core.registration import call_registration_hooks from ..core.undo import Command, HistoryManager from ..doceditor.editor import DocEditor -from ..doceditor.frame_popover import FramePopover +from .doceditor.frame_popover import FramePopover from ..machine.cmd import MachineCmd from ..machine.driver.driver import DeviceState, DeviceStatus from ..machine.driver.dummy import NoDeviceDriver From 7e4d2d49b23f69d757ad2395372f59e49a6970e6 Mon Sep 17 00:00:00 2001 From: Samuel Abels Date: Thu, 15 Oct 2026 00:50:28 +0200 Subject: [PATCH 076/104] refactor(image-edit): restyle the Adjust Image dialog The dialog opens non-modal and adopts the step settings dialog's structure: a toolbar view with the page in a scrolled window, 600 px wide, so the preferences margins match it exactly. Brightness, contrast, gamma and sharpen become sliders with percent entries like the laser page's power row, with the 250 ms preview debounce moving into the dialog. Refs #515 --- .../image_editing/adjust_dialog.py | 100 +++++++++++++----- .../tests/ui_gtk/test_adjust_dialog.py | 15 ++- 2 files changed, 81 insertions(+), 34 deletions(-) diff --git a/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/adjust_dialog.py b/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/adjust_dialog.py index eb4e13d73..43b13fefa 100644 --- a/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/adjust_dialog.py +++ b/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/adjust_dialog.py @@ -3,14 +3,15 @@ from collections.abc import Sequence from gettext import gettext as _ -from gi.repository import Adw, Gtk +from gi.repository import Adw, GLib, Gtk from rayforge.core.undo import HistoryManager from rayforge.core.workpiece import WorkPiece from rayforge.ui_gtk.shared.patched_dialog_window import ( PatchedDialogWindow, ) -from rayforge.ui_gtk.shared.pref_rows import LengthSpinRow, SpinRow +from rayforge.ui_gtk.shared.pref_rows import LengthSpinRow +from rayforge.ui_gtk.shared.slider import create_slider_row from .adjustments import ImageAdjustments from .commands import replace_op_edits, set_op_edits @@ -18,6 +19,9 @@ #: Delay before an edited value updates the canvas preview. PREVIEW_DELAY_MS = 250 +#: The dialog width, matching the step settings dialog. +DEFAULT_WIDTH = 600 + class AdjustImageDialog(PatchedDialogWindow): """ @@ -37,15 +41,25 @@ def __init__( self._workpieces = list(workpieces) self._originals = {wp.uid: wp.image_edits for wp in self._workpieces} self._applied = False + self._preview_timer_id: int | None = None self.set_title(_("Adjust Image")) - self.set_default_size(440, -1) - self.set_modal(True) + self.set_default_size(DEFAULT_WIDTH, -1) - box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - box.append(self._build_header()) - box.append(self._build_page()) - self.set_content(box) + main_view = Adw.ToolbarView() + main_view.add_top_bar(self._build_header()) + scrolled = Gtk.ScrolledWindow( + hscrollbar_policy=Gtk.PolicyType.NEVER, + vscrollbar_policy=Gtk.PolicyType.AUTOMATIC, + ) + page = self._build_page() + scrolled.set_child(page) + natural_height = page.measure(Gtk.Orientation.VERTICAL, DEFAULT_WIDTH)[ + 1 + ] + scrolled.set_min_content_height(natural_height) + main_view.set_content(scrolled) + self.set_content(main_view) first = self._workpieces[0].image_edits if self._workpieces else () self.set_adjustments(ImageAdjustments.from_edits(first)) @@ -62,33 +76,45 @@ def _build_header(self) -> Adw.HeaderBar: header.pack_end(apply_btn) return header - def _spin_row(self, title, subtitle, lower, upper, step, digits): - row = SpinRow( - title, - subtitle, + def _slider_row( + self, title, subtitle, lower, upper, step, digits, format_suffix=None + ): + adjustment = Gtk.Adjustment( + value=lower, lower=lower, upper=upper, step_increment=step, + page_increment=step * 10, + ) + return create_slider_row( + title=title, + subtitle=subtitle, + adjustment=adjustment, digits=digits, - numeric=True, - debounce_ms=PREVIEW_DELAY_MS, + on_value_changed=self._schedule_preview, + format_suffix=format_suffix, ) - row.value_changed.connect(lambda *a: self.preview()) - return row def _build_page(self) -> Adw.PreferencesPage: - self.brightness_row = self._spin_row( - _("Brightness"), _("Lighten or darken the image"), -100, 100, 1, 0 + self.brightness_row, self.brightness_scale = self._slider_row( + _("Brightness"), + _("Lighten or darken the image"), + -100, + 100, + 1, + 0, + format_suffix="%", ) - self.contrast_row = self._spin_row( + self.contrast_row, self.contrast_scale = self._slider_row( _("Contrast"), _("Spread or compress the tones around mid-gray"), -100, 100, 1, 0, + format_suffix="%", ) - self.gamma_row = self._spin_row( + self.gamma_row, self.gamma_scale = self._slider_row( _("Gamma"), _("Values above 1 lighten the midtones, below 1 darken them"), 0.2, @@ -96,13 +122,14 @@ def _build_page(self) -> Adw.PreferencesPage: 0.05, 2, ) - self.sharpen_row = self._spin_row( + self.sharpen_row, self.sharpen_scale = self._slider_row( _("Sharpen"), _("Strength of the unsharp mask, in percent"), 0, 300, 5, 0, + format_suffix="%", ) self.radius_row = LengthSpinRow( _("Sharpen Radius"), @@ -128,20 +155,32 @@ def _build_page(self) -> Adw.PreferencesPage: def get_adjustments(self) -> ImageAdjustments: return ImageAdjustments( - brightness=self.brightness_row.get_value(), - contrast=self.contrast_row.get_value(), - gamma=self.gamma_row.get_value(), - sharpen_amount=self.sharpen_row.get_value(), + brightness=self.brightness_scale.get_value(), + contrast=self.contrast_scale.get_value(), + gamma=self.gamma_scale.get_value(), + sharpen_amount=self.sharpen_scale.get_value(), sharpen_radius_mm=self.radius_row.get_value_in_base_units(), ) def set_adjustments(self, values: ImageAdjustments) -> None: - self.brightness_row.set_value(values.brightness) - self.contrast_row.set_value(values.contrast) - self.gamma_row.set_value(values.gamma) - self.sharpen_row.set_value(values.sharpen_amount) + self.brightness_scale.set_value(values.brightness) + self.contrast_scale.set_value(values.contrast) + self.gamma_scale.set_value(values.gamma) + self.sharpen_scale.set_value(values.sharpen_amount) self.radius_row.set_value_in_base_units(values.sharpen_radius_mm) + def _schedule_preview(self, *args) -> None: + if self._preview_timer_id is not None: + GLib.source_remove(self._preview_timer_id) + self._preview_timer_id = GLib.timeout_add( + PREVIEW_DELAY_MS, self._flush_preview + ) + + def _flush_preview(self) -> bool: + self._preview_timer_id = None + self.preview() + return GLib.SOURCE_REMOVE + def preview(self) -> None: """Shows the current values on the canvas, without undo.""" edits = self.get_adjustments().to_edits() @@ -164,6 +203,9 @@ def apply(self) -> None: self.close() def _on_close_request(self, *args) -> bool: + if self._preview_timer_id is not None: + GLib.source_remove(self._preview_timer_id) + self._preview_timer_id = None if not self._applied: for wp in self._workpieces: wp.image_edits = self._originals[wp.uid] diff --git a/rayforge/builtin_addons/rayforge-addon-image-editing/tests/ui_gtk/test_adjust_dialog.py b/rayforge/builtin_addons/rayforge-addon-image-editing/tests/ui_gtk/test_adjust_dialog.py index f84ddb2df..46a5fc0af 100644 --- a/rayforge/builtin_addons/rayforge-addon-image-editing/tests/ui_gtk/test_adjust_dialog.py +++ b/rayforge/builtin_addons/rayforge-addon-image-editing/tests/ui_gtk/test_adjust_dialog.py @@ -42,9 +42,14 @@ def test_opens_with_the_first_workpiece_values(dialog): assert dialog.get_adjustments() == ImageAdjustments(brightness=10) +@pytest.mark.ui +def test_is_not_modal(dialog): + assert not dialog.get_modal() + + @pytest.mark.ui def test_preview_shows_without_undo(dialog, workpieces): - dialog.contrast_row.set_value(20) + dialog.contrast_scale.set_value(20) dialog.preview() a, b = workpieces assert a.image_edits[0].params["contrast"] == 20 @@ -55,7 +60,7 @@ def test_preview_shows_without_undo(dialog, workpieces): @pytest.mark.ui def test_closing_without_apply_restores_the_edits(dialog, workpieces): - dialog.sharpen_row.set_value(100) + dialog.sharpen_scale.set_value(100) dialog.preview() dialog.close() assert workpieces[0].image_edits == (TONE,) @@ -65,7 +70,7 @@ def test_closing_without_apply_restores_the_edits(dialog, workpieces): @pytest.mark.ui def test_apply_is_one_undo_step(dialog, workpieces): history = dialog._history - dialog.gamma_row.set_value(1.5) + dialog.gamma_scale.set_value(1.5) dialog.preview() dialog.apply() assert workpieces[0].image_edits[0].params["gamma"] == 1.5 @@ -92,14 +97,14 @@ def test_previews_do_not_accumulate_cached_images(dialog, workpieces): for value in range(10): for wp in workpieces: _cache_a_render(wp) - dialog.brightness_row.set_value(value) + dialog.brightness_scale.set_value(value) dialog.preview() assert all(len(wp._processed_cache) == 0 for wp in workpieces) @pytest.mark.ui def test_closing_drops_the_preview_images(dialog, workpieces): - dialog.sharpen_row.set_value(100) + dialog.sharpen_scale.set_value(100) dialog.preview() for wp in workpieces: _cache_a_render(wp) From f758f72a55d5c8db603d9272802cae822a61f1ae Mon Sep 17 00:00:00 2001 From: Samuel Abels Date: Thu, 15 Oct 2026 01:25:21 +0200 Subject: [PATCH 077/104] docs(image-edit): show the Adjust Image dialog in the docs A new screenshot target stages the example photo, opens the dialog with the brightness, contrast and sharpen sliders set, and captures it as addon-image-editing-adjust.webp. The docs embed it after the Adjust Image settings, in every language. --- scripts/screenshot/adjust_image_dialog.py | 121 ++++++++++++++++++ scripts/screenshot/cli.py | 1 + website/docs/addons/image-editing.md | 2 + .../current/addons/image-editing.md | 2 + .../current/addons/image-editing.md | 2 + .../current/addons/image-editing.md | 2 + .../current/addons/image-editing.md | 2 + .../current/addons/image-editing.md | 2 + .../current/addons/image-editing.md | 2 + .../current/addons/image-editing.md | 2 + .../addon-image-editing-adjust.webp | Bin 0 -> 16362 bytes 11 files changed, 138 insertions(+) create mode 100644 scripts/screenshot/adjust_image_dialog.py create mode 100644 website/static/screenshots/addon-image-editing-adjust.webp diff --git a/scripts/screenshot/adjust_image_dialog.py b/scripts/screenshot/adjust_image_dialog.py new file mode 100644 index 000000000..e48f01b2f --- /dev/null +++ b/scripts/screenshot/adjust_image_dialog.py @@ -0,0 +1,121 @@ +""" +Screenshot: The Adjust Image dialog. + +Usage: pixi run screenshot addon:image-editing:adjust +""" + +import logging +import os +import sys +import tempfile +import time +from pathlib import Path + +from PIL import Image +from utils import ( + get_target, + restore_config, + run_on_main_thread, + set_window_size, + take_screenshot, + wait_for_settled, +) + +from rayforge.uiscript import app, win + +logger = logging.getLogger(__name__) + +TARGET = get_target("addon:image-editing:adjust") + +# Addons are loaded by path at runtime, not importable by name; the +# tests' root conftest puts their directories on sys.path likewise. +ADDON_DIR = ( + Path(__file__).parent.parent.parent + / "rayforge" + / "builtin_addons" + / "rayforge-addon-image-editing" +) +sys.path.insert(0, str(ADDON_DIR)) + +EXAMPLE_IMAGE = ( + Path(__file__).parent.parent.parent + / "website" + / "static" + / "images" + / "image-edit-invert-before.webp" +) + + +def _import_example_image() -> Path: + """Import the docs' example photo as a PNG, and return its path. + + The import reads the file from a background thread, so the path + stays alive until the document has settled; the caller removes it. + """ + fd, png_path = tempfile.mkstemp(prefix="rayforge-shot-", suffix=".png") + os.close(fd) + Image.open(EXAMPLE_IMAGE).save(png_path, format="PNG") + win.doc_editor.file.load_file_from_path(Path(png_path), "image/png", None) + return Path(png_path) + + +def _select_all_items(): + """Select all content items on the active layer.""" + win.surface.update_from_doc() + layer = win.doc_editor.doc.active_layer + items = layer.get_content_items() + if items: + win.surface.select_items(items) + return items + + +def _stage_values(dialog): + """Set the sliders to the values shown in the documentation.""" + dialog.brightness_scale.set_value(10) + dialog.contrast_scale.set_value(25) + dialog.sharpen_scale.set_value(50) + + +@restore_config +def main(): + set_window_size(win, 1400, 1000) + + png_path = run_on_main_thread(_import_example_image) + if not wait_for_settled(win, timeout=10): + logger.error("Document did not settle in time") + app.quit_idle() + return + png_path.unlink(missing_ok=True) + + items = run_on_main_thread(_select_all_items) + if not items: + logger.error("No items found in document") + app.quit_idle() + return + logger.info(f"Selected {len(items)} items") + + from image_editing.adjust_dialog import AdjustImageDialog + + def open_dialog(): + dialog = AdjustImageDialog( + win, + win.doc_editor.history_manager, + win.surface.get_selected_workpieces(), + ) + dialog.present() + return dialog + + dialog = run_on_main_thread(open_dialog) + time.sleep(0.5) + + run_on_main_thread(lambda: _stage_values(dialog)) + time.sleep(1.0) + + logger.info("Taking screenshot: %s", TARGET) + take_screenshot() + + run_on_main_thread(dialog.close) + app.quit_idle() + + +main() diff --git a/scripts/screenshot/cli.py b/scripts/screenshot/cli.py index 025ffbe68..5a494784b 100755 --- a/scripts/screenshot/cli.py +++ b/scripts/screenshot/cli.py @@ -22,6 +22,7 @@ TARGETS = { "addon:ai-workpiece-generator": "ai_workpiece_generator", "addon:deepnest": "deepnest", + "addon:image-editing:adjust": "adjust_image_dialog", "addon:print-and-cut:pick": "print_and_cut", "addon:print-and-cut:jog": "print_and_cut", "addon:print-and-cut:apply": "print_and_cut", diff --git a/website/docs/addons/image-editing.md b/website/docs/addons/image-editing.md index cf9353ef9..30a2aa3a4 100644 --- a/website/docs/addons/image-editing.md +++ b/website/docs/addons/image-editing.md @@ -40,6 +40,8 @@ engraving or tracing it: of the details it works on, in millimeters, so the result is the same at any line interval or canvas zoom. +![The Adjust Image dialog with the tone and sharpness sliders](/screenshots/addon-image-editing-adjust.webp) + The canvas shows the changes while you edit. **Apply** keeps them as a single undo step; closing the dialog without applying discards them, and **Reset** sets every value back to neutral. Transparent areas stay transparent. diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/addons/image-editing.md b/website/i18n/de/docusaurus-plugin-content-docs/current/addons/image-editing.md index 2a2515e94..4a099e8e6 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/addons/image-editing.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/addons/image-editing.md @@ -38,6 +38,8 @@ Gravieren oder Nachzeichnen zu korrigieren: Radius** legt die Größe der Details in Millimetern fest, sodass das Ergebnis bei jedem Zeilenabstand und jeder Zoomstufe gleich ist. +![Der Dialog „Bild anpassen“ mit den Reglern für Tonwert und Schärfe](/screenshots/addon-image-editing-adjust.webp) + Die Leinwand zeigt die Änderungen schon während der Bearbeitung. **Apply** übernimmt sie als einen einzigen Rückgängig-Schritt; wenn du den Dialog ohne Übernehmen schließt, werden sie verworfen, und **Reset** setzt alle Werte auf neutral zurück. Transparente Bereiche bleiben transparent. diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/addons/image-editing.md b/website/i18n/es/docusaurus-plugin-content-docs/current/addons/image-editing.md index 5bfe515f0..26f6d88f2 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/addons/image-editing.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/addons/image-editing.md @@ -36,6 +36,8 @@ antes de grabarla o vectorizarla: Radius** fija el tamaño de esos detalles en milímetros, de modo que el resultado es el mismo con cualquier intervalo de línea o nivel de zoom. +![El diálogo de Ajustar imagen con los controles deslizantes de tono y nitidez](/screenshots/addon-image-editing-adjust.webp) + El lienzo muestra los cambios mientras editas. **Apply** los conserva como un único paso de deshacer; si cierras el diálogo sin aplicar, se descartan, y **Reset** devuelve todos los valores a neutro. Las zonas transparentes siguen siendo transparentes. diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/addons/image-editing.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/addons/image-editing.md index c2b8d00e1..d963fc60d 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/addons/image-editing.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/addons/image-editing.md @@ -36,6 +36,8 @@ photo avant de la graver ou de la vectoriser : Radius** fixe la taille de ces détails en millimètres, si bien que le résultat est le même quel que soit l'intervalle de ligne ou le zoom. +![La boîte de dialogue Ajuster l'image avec les curseurs de tonalité et de netteté](/screenshots/addon-image-editing-adjust.webp) + Le canevas affiche les modifications pendant l'édition. **Apply** les conserve en une seule étape d'annulation ; fermer la boîte de dialogue sans appliquer les abandonne, et **Reset** remet toutes les valeurs au neutre. Les zones transparentes restent transparentes. diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/addons/image-editing.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/addons/image-editing.md index bcca3faed..cc303c573 100644 --- a/website/i18n/hi/docusaurus-plugin-content-docs/current/addons/image-editing.md +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/addons/image-editing.md @@ -36,6 +36,8 @@ Adjust Image...** चुनें: Radius** उन विवरणों का आकार मिलीमीटर में तय करता है, इसलिए परिणाम किसी भी लाइन अंतराल या ज़ूम पर एक जैसा रहता है। +![टोन और शार्पनेस स्लाइडर के साथ छवि समायोजित करें डायलॉग](/screenshots/addon-image-editing-adjust.webp) + संपादन करते समय कैनवास बदलाव दिखाता है। **Apply** उन्हें एक ही अनडू चरण के रूप में रखता है; बिना लागू किए डायलॉग बंद करने पर वे हट जाते हैं, और **Reset** हर मान को फिर से तटस्थ कर देता है। पारदर्शी क्षेत्र पारदर्शी ही रहते हैं। diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/addons/image-editing.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/addons/image-editing.md index 15fbba9cd..2959c38b4 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/addons/image-editing.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/addons/image-editing.md @@ -36,6 +36,8 @@ gravá-la ou vetorizá-la: define o tamanho desses detalhes em milímetros, então o resultado é o mesmo em qualquer intervalo de linha ou zoom. +![A caixa de diálogo Ajustar imagem com os controles deslizantes de tom e nitidez](/screenshots/addon-image-editing-adjust.webp) + A tela mostra as alterações enquanto você edita. **Apply** as mantém como uma única etapa de desfazer; fechar a caixa de diálogo sem aplicar as descarta, e **Reset** volta todos os valores ao neutro. As áreas transparentes continuam transparentes. diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/addons/image-editing.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/addons/image-editing.md index 35a18dd3a..500b9aafc 100644 --- a/website/i18n/uk/docusaurus-plugin-content-docs/current/addons/image-editing.md +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/addons/image-editing.md @@ -35,6 +35,8 @@ задає розмір цих деталей у міліметрах, тож результат однаковий за будь-якого інтервалу ліній чи масштабу. +![Діалогове вікно коригування зображення з повзунками тону та різкості](/screenshots/addon-image-editing-adjust.webp) + Полотно показує зміни під час редагування. **Apply** зберігає їх як один крок скасування; якщо закрити діалог без застосування, вони відкидаються, а **Reset** повертає всі значення до нейтральних. Прозорі ділянки залишаються прозорими. diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/addons/image-editing.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/addons/image-editing.md index 4c199495f..cc8397770 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/addons/image-editing.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/addons/image-editing.md @@ -27,6 +27,8 @@ Image**。浅色区域变深,深色区域变浅;透明区域保持透明, - **Sharpen**(锐化)增强边缘和细节(USM 锐化)。**Sharpen Radius** 以毫米为单位设置这些细节的大小,因此无论行间距或缩放如何,结果都相同。 +![带有色调和锐化滑块的调整图像对话框](/screenshots/addon-image-editing-adjust.webp) + 编辑时画布会实时显示更改。**Apply** 将其作为一个撤销步骤保留;不应用就关闭对话框会放弃更改, **Reset** 将所有值恢复为中性。透明区域保持透明。 diff --git a/website/static/screenshots/addon-image-editing-adjust.webp b/website/static/screenshots/addon-image-editing-adjust.webp new file mode 100644 index 0000000000000000000000000000000000000000..44d89979699e7edebae519ed344fb00cb5fbaea2 GIT binary patch literal 16362 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 VTJS%J5#O%B{#UmAkK6C^{{g~*$m{?B literal 0 HcmV?d00001 From 1487d4b7e74a8cfab11ed32eb6ed97d130e3fa13 Mon Sep 17 00:00:00 2001 From: Samuel Abels Date: Thu, 15 Oct 2026 02:59:26 +0200 Subject: [PATCH 078/104] update translations --- .../locale/de/LC_MESSAGES/laser_essentials.po | 85 ++- .../locale/en/LC_MESSAGES/laser_essentials.po | 83 ++- .../locale/es/LC_MESSAGES/laser_essentials.po | 85 ++- .../locale/fr/LC_MESSAGES/laser_essentials.po | 85 ++- .../locale/hi/LC_MESSAGES/laser_essentials.po | 83 ++- .../locale/laser_essentials.pot | 77 +-- .../locale/pt/LC_MESSAGES/laser_essentials.po | 85 ++- .../locale/uk/LC_MESSAGES/laser_essentials.po | 85 ++- .../zh_CN/LC_MESSAGES/laser_essentials.po | 83 ++- rayforge/locale/de/LC_MESSAGES/rayforge.po | 560 +++++++++++++---- rayforge/locale/en/LC_MESSAGES/rayforge.po | 453 +++++++++++--- rayforge/locale/es/LC_MESSAGES/rayforge.po | 558 +++++++++++++---- rayforge/locale/fr/LC_MESSAGES/rayforge.po | 559 +++++++++++++---- rayforge/locale/hi/LC_MESSAGES/rayforge.po | 543 +++++++++++++---- rayforge/locale/pt/LC_MESSAGES/rayforge.po | 554 +++++++++++++---- rayforge/locale/rayforge.pot | 453 +++++++++++--- rayforge/locale/uk/LC_MESSAGES/rayforge.po | 564 ++++++++++++++---- rayforge/locale/zh_CN/LC_MESSAGES/rayforge.po | 523 ++++++++++++---- 18 files changed, 4223 insertions(+), 1295 deletions(-) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/locale/de/LC_MESSAGES/laser_essentials.po b/rayforge/builtin_addons/rayforge-addon-laser/locale/de/LC_MESSAGES/laser_essentials.po index 177c113ed..0fe245f8f 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/locale/de/LC_MESSAGES/laser_essentials.po +++ b/rayforge/builtin_addons/rayforge-addon-laser/locale/de/LC_MESSAGES/laser_essentials.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: Rayforge\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-24 21:41+0200\n" +"POT-Creation-Date: 2026-10-12 18:45+0200\n" "PO-Revision-Date: 2025-07-24 22:08+0200\n" "Last-Translator: Samuel Abels \n" "Language-Team: none\n" @@ -203,6 +203,10 @@ msgstr "Voreinstellungen" msgid "Load common test configurations" msgstr "Häufige Testkonfigurationen laden" +#: laser_essentials/widgets/raster_power_widget.py +msgid "Min Power (White)" +msgstr "Minimale Leistung (Weiß)" + #: laser_essentials/widgets/raster_power_widget.py #: laser_essentials/steps/raster_step.py msgid "Power for lightest areas, as a percentage of the step's main power" @@ -210,6 +214,10 @@ msgstr "" "Leistung für die hellsten Bereiche, als Prozent der Hauptleistung des " "Schritts" +#: laser_essentials/widgets/raster_power_widget.py +msgid "Max Power (Black)" +msgstr "Maximale Leistung (Schwarz)" + #: laser_essentials/widgets/raster_power_widget.py #: laser_essentials/steps/raster_step.py msgid "Power for darkest areas, as a percentage of the step's main power" @@ -217,22 +225,6 @@ msgstr "" "Leistung für die dunkelsten Bereiche, als Prozent der Hauptleistung des " "Schritts" -#: laser_essentials/widgets/raster_power_widget.py -msgid "Min Power (Black)" -msgstr "Min. Leistung (Schwarz)" - -#: laser_essentials/widgets/raster_power_widget.py -msgid "Max Power (White)" -msgstr "Max. Leistung (Weiß)" - -#: laser_essentials/widgets/raster_power_widget.py -msgid "Min Power (White)" -msgstr "Minimale Leistung (Weiß)" - -#: laser_essentials/widgets/raster_power_widget.py -msgid "Max Power (Black)" -msgstr "Maximale Leistung (Schwarz)" - #: laser_essentials/widgets/raster_page.py msgid "Engrave Speed" msgstr "Graviergeschwindigkeit" @@ -422,6 +414,33 @@ msgstr "Gravurmethode" msgid "Algorithm for converting grayscale to binary" msgstr "Algorithmus zum Konvertieren von Graustufen in Binär" +#: laser_essentials/steps/raster_step.py +msgid "Serpentine" +msgstr "Serpentinenmodus" + +#: laser_essentials/steps/raster_step.py +msgid "" +"Alternate the dither direction on every row to avoid directional artifacts" +msgstr "" +"Ditherrichtung in jeder Zeile wechseln, um richtungsabhängige Artefakte zu " +"vermeiden" + +#: laser_essentials/steps/raster_step.py +msgid "Halftone Cell Size" +msgstr "Halbton-Zellgröße" + +#: laser_essentials/steps/raster_step.py +msgid "Distance between halftone dot centers" +msgstr "Abstand zwischen den Mittelpunkten der Halbtonpunkte" + +#: laser_essentials/steps/raster_step.py +msgid "Halftone Angle" +msgstr "Halbton-Winkel" + +#: laser_essentials/steps/raster_step.py +msgid "Rotation of the halftone dot grid" +msgstr "Drehung des Halbton-Punktrasters" + #: laser_essentials/steps/raster_step.py msgid "Cross-Hatch" msgstr "Kreuzschraffur" @@ -492,14 +511,6 @@ msgstr "" "Korrigiert die X-Ausrichtung zwischen Rasterdurchgängen von links nachrechts " "und von rechts nach links" -#: laser_essentials/steps/raster_step.py -msgid "Invert" -msgstr "Invertieren" - -#: laser_essentials/steps/raster_step.py -msgid "Engrave white areas instead of black areas" -msgstr "Weiße Bereiche anstelle von schwarzen Bereichen gravieren" - #: laser_essentials/steps/raster_step.py msgid "Number of Depth Levels" msgstr "Anzahl der Tiefenstufen" @@ -802,27 +813,3 @@ msgstr "Laserkopf" #: laser_essentials/commands/material_test_cmd.py msgid "Add Material Test" msgstr "Materialtest hinzufügen" - -#: laser_essentials/steps/raster_step.py -msgid "Serpentine" -msgstr "Serpentinenmodus" - -#: laser_essentials/steps/raster_step.py -msgid "Alternate the dither direction on every row to avoid directional artifacts" -msgstr "Ditherrichtung in jeder Zeile wechseln, um richtungsabhängige Artefakte zu vermeiden" - -#: laser_essentials/steps/raster_step.py -msgid "Halftone Cell Size" -msgstr "Halbton-Zellgröße" - -#: laser_essentials/steps/raster_step.py -msgid "Distance between halftone dot centers" -msgstr "Abstand zwischen den Mittelpunkten der Halbtonpunkte" - -#: laser_essentials/steps/raster_step.py -msgid "Halftone Angle" -msgstr "Halbton-Winkel" - -#: laser_essentials/steps/raster_step.py -msgid "Rotation of the halftone dot grid" -msgstr "Drehung des Halbton-Punktrasters" diff --git a/rayforge/builtin_addons/rayforge-addon-laser/locale/en/LC_MESSAGES/laser_essentials.po b/rayforge/builtin_addons/rayforge-addon-laser/locale/en/LC_MESSAGES/laser_essentials.po index aa82df27d..6fcd44a80 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/locale/en/LC_MESSAGES/laser_essentials.po +++ b/rayforge/builtin_addons/rayforge-addon-laser/locale/en/LC_MESSAGES/laser_essentials.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-24 21:41+0200\n" +"POT-Creation-Date: 2026-10-12 18:45+0200\n" "PO-Revision-Date: 2026-03-01 19:25+0100\n" "Last-Translator: Automatically generated\n" "Language-Team: none\n" @@ -201,32 +201,24 @@ msgstr "Presets" msgid "Load common test configurations" msgstr "Load common test configurations" +#: laser_essentials/widgets/raster_power_widget.py +msgid "Min Power (White)" +msgstr "Min Power (White)" + #: laser_essentials/widgets/raster_power_widget.py #: laser_essentials/steps/raster_step.py msgid "Power for lightest areas, as a percentage of the step's main power" msgstr "Power for lightest areas, as a percentage of the step's main power" +#: laser_essentials/widgets/raster_power_widget.py +msgid "Max Power (Black)" +msgstr "Max Power (Black)" + #: laser_essentials/widgets/raster_power_widget.py #: laser_essentials/steps/raster_step.py msgid "Power for darkest areas, as a percentage of the step's main power" msgstr "Power for darkest areas, as a percentage of the step's main power" -#: laser_essentials/widgets/raster_power_widget.py -msgid "Min Power (Black)" -msgstr "Min Power (Black)" - -#: laser_essentials/widgets/raster_power_widget.py -msgid "Max Power (White)" -msgstr "Max Power (White)" - -#: laser_essentials/widgets/raster_power_widget.py -msgid "Min Power (White)" -msgstr "Min Power (White)" - -#: laser_essentials/widgets/raster_power_widget.py -msgid "Max Power (Black)" -msgstr "Max Power (Black)" - #: laser_essentials/widgets/raster_page.py msgid "Engrave Speed" msgstr "" @@ -406,6 +398,31 @@ msgstr "Engraving Method" msgid "Algorithm for converting grayscale to binary" msgstr "Algorithm for converting grayscale to binary" +#: laser_essentials/steps/raster_step.py +msgid "Serpentine" +msgstr "" + +#: laser_essentials/steps/raster_step.py +msgid "" +"Alternate the dither direction on every row to avoid directional artifacts" +msgstr "" + +#: laser_essentials/steps/raster_step.py +msgid "Halftone Cell Size" +msgstr "" + +#: laser_essentials/steps/raster_step.py +msgid "Distance between halftone dot centers" +msgstr "" + +#: laser_essentials/steps/raster_step.py +msgid "Halftone Angle" +msgstr "" + +#: laser_essentials/steps/raster_step.py +msgid "Rotation of the halftone dot grid" +msgstr "" + #: laser_essentials/steps/raster_step.py msgid "Cross-Hatch" msgstr "Cross-Hatch" @@ -468,14 +485,6 @@ msgid "" "Corrects X misalignment between left-to-right and right-to-left raster passes" msgstr "" -#: laser_essentials/steps/raster_step.py -msgid "Invert" -msgstr "Invert" - -#: laser_essentials/steps/raster_step.py -msgid "Engrave white areas instead of black areas" -msgstr "Engrave white areas instead of black areas" - #: laser_essentials/steps/raster_step.py msgid "Number of Depth Levels" msgstr "Number of Depth Levels" @@ -770,27 +779,3 @@ msgstr "" #: laser_essentials/commands/material_test_cmd.py msgid "Add Material Test" msgstr "Add Material Test" - -#: laser_essentials/steps/raster_step.py -msgid "Serpentine" -msgstr "" - -#: laser_essentials/steps/raster_step.py -msgid "Alternate the dither direction on every row to avoid directional artifacts" -msgstr "" - -#: laser_essentials/steps/raster_step.py -msgid "Halftone Cell Size" -msgstr "" - -#: laser_essentials/steps/raster_step.py -msgid "Distance between halftone dot centers" -msgstr "" - -#: laser_essentials/steps/raster_step.py -msgid "Halftone Angle" -msgstr "" - -#: laser_essentials/steps/raster_step.py -msgid "Rotation of the halftone dot grid" -msgstr "" diff --git a/rayforge/builtin_addons/rayforge-addon-laser/locale/es/LC_MESSAGES/laser_essentials.po b/rayforge/builtin_addons/rayforge-addon-laser/locale/es/LC_MESSAGES/laser_essentials.po index ad2cc4de2..886aec60c 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/locale/es/LC_MESSAGES/laser_essentials.po +++ b/rayforge/builtin_addons/rayforge-addon-laser/locale/es/LC_MESSAGES/laser_essentials.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: Rayforge\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-24 21:41+0200\n" +"POT-Creation-Date: 2026-10-12 18:45+0200\n" "PO-Revision-Date: 2025-08-08 10:00+0200\n" "Last-Translator: Samuel Abels\n" "Language-Team: Spanish \n" @@ -203,6 +203,10 @@ msgstr "Preajustes" msgid "Load common test configurations" msgstr "Cargar configuraciones de prueba comunes" +#: laser_essentials/widgets/raster_power_widget.py +msgid "Min Power (White)" +msgstr "Potencia mín. (Blanco)" + #: laser_essentials/widgets/raster_power_widget.py #: laser_essentials/steps/raster_step.py msgid "Power for lightest areas, as a percentage of the step's main power" @@ -210,6 +214,10 @@ msgstr "" "Potencia para las áreas más claras, como porcentaje de la potencia principal " "del paso." +#: laser_essentials/widgets/raster_power_widget.py +msgid "Max Power (Black)" +msgstr "Potencia máx. (Negro)" + #: laser_essentials/widgets/raster_power_widget.py #: laser_essentials/steps/raster_step.py msgid "Power for darkest areas, as a percentage of the step's main power" @@ -217,22 +225,6 @@ msgstr "" "Potencia para las áreas más oscuras, como porcentaje de la potencia " "principal del paso." -#: laser_essentials/widgets/raster_power_widget.py -msgid "Min Power (Black)" -msgstr "Potencia mínima (Negro)" - -#: laser_essentials/widgets/raster_power_widget.py -msgid "Max Power (White)" -msgstr "Potencia máxima (Blanco)" - -#: laser_essentials/widgets/raster_power_widget.py -msgid "Min Power (White)" -msgstr "Potencia mín. (Blanco)" - -#: laser_essentials/widgets/raster_power_widget.py -msgid "Max Power (Black)" -msgstr "Potencia máx. (Negro)" - #: laser_essentials/widgets/raster_page.py msgid "Engrave Speed" msgstr "Velocidad de grabado" @@ -424,6 +416,33 @@ msgstr "Método de grabado" msgid "Algorithm for converting grayscale to binary" msgstr "Algoritmo para convertir escala de grises a binario" +#: laser_essentials/steps/raster_step.py +msgid "Serpentine" +msgstr "Serpentina" + +#: laser_essentials/steps/raster_step.py +msgid "" +"Alternate the dither direction on every row to avoid directional artifacts" +msgstr "" +"Alternar la dirección del difuminado en cada fila para evitar artefactos " +"direccionales" + +#: laser_essentials/steps/raster_step.py +msgid "Halftone Cell Size" +msgstr "Tamaño de celda del semitono" + +#: laser_essentials/steps/raster_step.py +msgid "Distance between halftone dot centers" +msgstr "Distancia entre los centros de los puntos del semitono" + +#: laser_essentials/steps/raster_step.py +msgid "Halftone Angle" +msgstr "Ángulo del semitono" + +#: laser_essentials/steps/raster_step.py +msgid "Rotation of the halftone dot grid" +msgstr "Rotación de la cuadrícula de puntos del semitono" + #: laser_essentials/steps/raster_step.py msgid "Cross-Hatch" msgstr "Trama cruzada" @@ -494,14 +513,6 @@ msgstr "" "Corrige la desalineación en X entre pasadas de rasterizado de izquierda " "aderecha y de derecha a izquierda" -#: laser_essentials/steps/raster_step.py -msgid "Invert" -msgstr "Invertir" - -#: laser_essentials/steps/raster_step.py -msgid "Engrave white areas instead of black areas" -msgstr "Grabar áreas blancas en lugar de áreas negras" - #: laser_essentials/steps/raster_step.py msgid "Number of Depth Levels" msgstr "Número de niveles de profundidad" @@ -806,27 +817,3 @@ msgstr "Cabezal láser" #: laser_essentials/commands/material_test_cmd.py msgid "Add Material Test" msgstr "Añadir prueba de material" - -#: laser_essentials/steps/raster_step.py -msgid "Serpentine" -msgstr "Serpentina" - -#: laser_essentials/steps/raster_step.py -msgid "Alternate the dither direction on every row to avoid directional artifacts" -msgstr "Alternar la dirección del difuminado en cada fila para evitar artefactos direccionales" - -#: laser_essentials/steps/raster_step.py -msgid "Halftone Cell Size" -msgstr "Tamaño de celda del semitono" - -#: laser_essentials/steps/raster_step.py -msgid "Distance between halftone dot centers" -msgstr "Distancia entre los centros de los puntos del semitono" - -#: laser_essentials/steps/raster_step.py -msgid "Halftone Angle" -msgstr "Ángulo del semitono" - -#: laser_essentials/steps/raster_step.py -msgid "Rotation of the halftone dot grid" -msgstr "Rotación de la cuadrícula de puntos del semitono" diff --git a/rayforge/builtin_addons/rayforge-addon-laser/locale/fr/LC_MESSAGES/laser_essentials.po b/rayforge/builtin_addons/rayforge-addon-laser/locale/fr/LC_MESSAGES/laser_essentials.po index 1ea71b7c2..08c83ebc9 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/locale/fr/LC_MESSAGES/laser_essentials.po +++ b/rayforge/builtin_addons/rayforge-addon-laser/locale/fr/LC_MESSAGES/laser_essentials.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: rayforge\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-24 21:41+0200\n" +"POT-Creation-Date: 2026-10-12 18:45+0200\n" "PO-Revision-Date: 2025-10-15 07:10+0200\n" "Last-Translator: Samuel \n" "Language-Team: French\n" @@ -202,6 +202,10 @@ msgstr "Préréglages" msgid "Load common test configurations" msgstr "Charger les configurations de test courantes" +#: laser_essentials/widgets/raster_power_widget.py +msgid "Min Power (White)" +msgstr "Puissance min (Blanc)" + #: laser_essentials/widgets/raster_power_widget.py #: laser_essentials/steps/raster_step.py msgid "Power for lightest areas, as a percentage of the step's main power" @@ -209,6 +213,10 @@ msgstr "" "Puissance pour les zones les plus claires, en pourcentage de la puissance " "principale de l’étape" +#: laser_essentials/widgets/raster_power_widget.py +msgid "Max Power (Black)" +msgstr "Puissance max (Noir)" + #: laser_essentials/widgets/raster_power_widget.py #: laser_essentials/steps/raster_step.py msgid "Power for darkest areas, as a percentage of the step's main power" @@ -216,22 +224,6 @@ msgstr "" "Puissance pour les zones les plus sombres, en pourcentage de la puissance " "principale de l’étape" -#: laser_essentials/widgets/raster_power_widget.py -msgid "Min Power (Black)" -msgstr "Puissance minimale (Noir)" - -#: laser_essentials/widgets/raster_power_widget.py -msgid "Max Power (White)" -msgstr "Puissance maximale (Blanc)" - -#: laser_essentials/widgets/raster_power_widget.py -msgid "Min Power (White)" -msgstr "Puissance min (Blanc)" - -#: laser_essentials/widgets/raster_power_widget.py -msgid "Max Power (Black)" -msgstr "Puissance max (Noir)" - #: laser_essentials/widgets/raster_page.py msgid "Engrave Speed" msgstr "Vitesse de gravure" @@ -422,6 +414,33 @@ msgstr "Méthode de gravure" msgid "Algorithm for converting grayscale to binary" msgstr "Algorithme de conversion du niveaux de gris en binaire" +#: laser_essentials/steps/raster_step.py +msgid "Serpentine" +msgstr "Serpentin" + +#: laser_essentials/steps/raster_step.py +msgid "" +"Alternate the dither direction on every row to avoid directional artifacts" +msgstr "" +"Alterner le sens du tramage à chaque ligne pour éviter les artefacts " +"directionnels" + +#: laser_essentials/steps/raster_step.py +msgid "Halftone Cell Size" +msgstr "Taille de cellule de demi-teinte" + +#: laser_essentials/steps/raster_step.py +msgid "Distance between halftone dot centers" +msgstr "Distance entre les centres des points de demi-teinte" + +#: laser_essentials/steps/raster_step.py +msgid "Halftone Angle" +msgstr "Angle de demi-teinte" + +#: laser_essentials/steps/raster_step.py +msgid "Rotation of the halftone dot grid" +msgstr "Rotation de la grille de points de demi-teinte" + #: laser_essentials/steps/raster_step.py msgid "Cross-Hatch" msgstr "Hachures croisées" @@ -493,14 +512,6 @@ msgstr "" "Corrige le désalignement en X entre les passages de rasterisation de gaucheà " "droite et de droite à gauche" -#: laser_essentials/steps/raster_step.py -msgid "Invert" -msgstr "Inverser" - -#: laser_essentials/steps/raster_step.py -msgid "Engrave white areas instead of black areas" -msgstr "Graver les zones blanches au lieu des zones noires" - #: laser_essentials/steps/raster_step.py msgid "Number of Depth Levels" msgstr "Nombre de niveaux de profondeur" @@ -803,27 +814,3 @@ msgstr "Tête laser" #: laser_essentials/commands/material_test_cmd.py msgid "Add Material Test" msgstr "Ajouter un test de matériau" - -#: laser_essentials/steps/raster_step.py -msgid "Serpentine" -msgstr "Serpentin" - -#: laser_essentials/steps/raster_step.py -msgid "Alternate the dither direction on every row to avoid directional artifacts" -msgstr "Alterner le sens du tramage à chaque ligne pour éviter les artefacts directionnels" - -#: laser_essentials/steps/raster_step.py -msgid "Halftone Cell Size" -msgstr "Taille de cellule de demi-teinte" - -#: laser_essentials/steps/raster_step.py -msgid "Distance between halftone dot centers" -msgstr "Distance entre les centres des points de demi-teinte" - -#: laser_essentials/steps/raster_step.py -msgid "Halftone Angle" -msgstr "Angle de demi-teinte" - -#: laser_essentials/steps/raster_step.py -msgid "Rotation of the halftone dot grid" -msgstr "Rotation de la grille de points de demi-teinte" diff --git a/rayforge/builtin_addons/rayforge-addon-laser/locale/hi/LC_MESSAGES/laser_essentials.po b/rayforge/builtin_addons/rayforge-addon-laser/locale/hi/LC_MESSAGES/laser_essentials.po index c935dfdd1..adf3e94f4 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/locale/hi/LC_MESSAGES/laser_essentials.po +++ b/rayforge/builtin_addons/rayforge-addon-laser/locale/hi/LC_MESSAGES/laser_essentials.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: Rayforge\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-24 21:41+0200\n" +"POT-Creation-Date: 2026-10-12 18:45+0200\n" "PO-Revision-Date: 2026-09-27 22:58\n" "Last-Translator: Samuel Abels \n" "Language-Team: none\n" @@ -201,32 +201,24 @@ msgstr "प्रीसेट" msgid "Load common test configurations" msgstr "सामान्य परीक्षण कॉन्फ़िगरेशन लोड करें" +#: laser_essentials/widgets/raster_power_widget.py +msgid "Min Power (White)" +msgstr "न्यूनतम शक्ति (सफ़ेद)" + #: laser_essentials/widgets/raster_power_widget.py #: laser_essentials/steps/raster_step.py msgid "Power for lightest areas, as a percentage of the step's main power" msgstr "सबसे उजले क्षेत्रों के लिए शक्ति, स्टेप की मुख्य शक्ति के प्रतिशत के रूप में" +#: laser_essentials/widgets/raster_power_widget.py +msgid "Max Power (Black)" +msgstr "अधिकतम शक्ति (काला)" + #: laser_essentials/widgets/raster_power_widget.py #: laser_essentials/steps/raster_step.py msgid "Power for darkest areas, as a percentage of the step's main power" msgstr "सबसे गहरे क्षेत्रों के लिए शक्ति, स्टेप की मुख्य शक्ति के प्रतिशत के रूप में" -#: laser_essentials/widgets/raster_power_widget.py -msgid "Min Power (Black)" -msgstr "न्यूनतम शक्ति (काला)" - -#: laser_essentials/widgets/raster_power_widget.py -msgid "Max Power (White)" -msgstr "अधिकतम शक्ति (सफ़ेद)" - -#: laser_essentials/widgets/raster_power_widget.py -msgid "Min Power (White)" -msgstr "न्यूनतम शक्ति (सफ़ेद)" - -#: laser_essentials/widgets/raster_power_widget.py -msgid "Max Power (Black)" -msgstr "अधिकतम शक्ति (काला)" - #: laser_essentials/widgets/raster_page.py msgid "Engrave Speed" msgstr "उत्कीर्णन गति" @@ -413,6 +405,31 @@ msgstr "उत्कीर्णन विधि" msgid "Algorithm for converting grayscale to binary" msgstr "ग्रेस्केल को बाइनरी में बदलने का एल्गोरिदम" +#: laser_essentials/steps/raster_step.py +msgid "Serpentine" +msgstr "सर्पाकार" + +#: laser_essentials/steps/raster_step.py +msgid "" +"Alternate the dither direction on every row to avoid directional artifacts" +msgstr "दिशात्मक दोषों से बचने के लिए हर पंक्ति में डाइदर की दिशा बदलें" + +#: laser_essentials/steps/raster_step.py +msgid "Halftone Cell Size" +msgstr "हाफ़टोन सेल आकार" + +#: laser_essentials/steps/raster_step.py +msgid "Distance between halftone dot centers" +msgstr "हाफ़टोन बिंदुओं के केंद्रों के बीच की दूरी" + +#: laser_essentials/steps/raster_step.py +msgid "Halftone Angle" +msgstr "हाफ़टोन कोण" + +#: laser_essentials/steps/raster_step.py +msgid "Rotation of the halftone dot grid" +msgstr "हाफ़टोन बिंदु ग्रिड का घुमाव" + #: laser_essentials/steps/raster_step.py msgid "Cross-Hatch" msgstr "क्रॉस-हैच" @@ -478,14 +495,6 @@ msgid "" "Corrects X misalignment between left-to-right and right-to-left raster passes" msgstr "बाएँ-से-दाएँ और दाएँ-से-बाएँ रास्टर पास के बीच X बेमेल ठीक करता है" -#: laser_essentials/steps/raster_step.py -msgid "Invert" -msgstr "उलटें" - -#: laser_essentials/steps/raster_step.py -msgid "Engrave white areas instead of black areas" -msgstr "काले क्षेत्रों के बजाय सफ़ेद क्षेत्र उत्कीर्ण करें" - #: laser_essentials/steps/raster_step.py msgid "Number of Depth Levels" msgstr "गहराई स्तरों की संख्या" @@ -784,27 +793,3 @@ msgstr "लेज़र हेड" #: laser_essentials/commands/material_test_cmd.py msgid "Add Material Test" msgstr "सामग्री परीक्षण जोड़ें" - -#: laser_essentials/steps/raster_step.py -msgid "Serpentine" -msgstr "सर्पाकार" - -#: laser_essentials/steps/raster_step.py -msgid "Alternate the dither direction on every row to avoid directional artifacts" -msgstr "दिशात्मक दोषों से बचने के लिए हर पंक्ति में डाइदर की दिशा बदलें" - -#: laser_essentials/steps/raster_step.py -msgid "Halftone Cell Size" -msgstr "हाफ़टोन सेल आकार" - -#: laser_essentials/steps/raster_step.py -msgid "Distance between halftone dot centers" -msgstr "हाफ़टोन बिंदुओं के केंद्रों के बीच की दूरी" - -#: laser_essentials/steps/raster_step.py -msgid "Halftone Angle" -msgstr "हाफ़टोन कोण" - -#: laser_essentials/steps/raster_step.py -msgid "Rotation of the halftone dot grid" -msgstr "हाफ़टोन बिंदु ग्रिड का घुमाव" diff --git a/rayforge/builtin_addons/rayforge-addon-laser/locale/laser_essentials.pot b/rayforge/builtin_addons/rayforge-addon-laser/locale/laser_essentials.pot index a312c2959..3c44118e5 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/locale/laser_essentials.pot +++ b/rayforge/builtin_addons/rayforge-addon-laser/locale/laser_essentials.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-24 21:41+0200\n" +"POT-Creation-Date: 2026-10-12 18:45+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -202,29 +202,21 @@ msgid "Load common test configurations" msgstr "" #: laser_essentials/widgets/raster_power_widget.py -#: laser_essentials/steps/raster_step.py -msgid "Power for lightest areas, as a percentage of the step's main power" +msgid "Min Power (White)" msgstr "" #: laser_essentials/widgets/raster_power_widget.py #: laser_essentials/steps/raster_step.py -msgid "Power for darkest areas, as a percentage of the step's main power" -msgstr "" - -#: laser_essentials/widgets/raster_power_widget.py -msgid "Min Power (Black)" -msgstr "" - -#: laser_essentials/widgets/raster_power_widget.py -msgid "Max Power (White)" +msgid "Power for lightest areas, as a percentage of the step's main power" msgstr "" #: laser_essentials/widgets/raster_power_widget.py -msgid "Min Power (White)" +msgid "Max Power (Black)" msgstr "" #: laser_essentials/widgets/raster_power_widget.py -msgid "Max Power (Black)" +#: laser_essentials/steps/raster_step.py +msgid "Power for darkest areas, as a percentage of the step's main power" msgstr "" #: laser_essentials/widgets/raster_page.py @@ -410,6 +402,31 @@ msgstr "" msgid "Algorithm for converting grayscale to binary" msgstr "" +#: laser_essentials/steps/raster_step.py +msgid "Serpentine" +msgstr "" + +#: laser_essentials/steps/raster_step.py +msgid "" +"Alternate the dither direction on every row to avoid directional artifacts" +msgstr "" + +#: laser_essentials/steps/raster_step.py +msgid "Halftone Cell Size" +msgstr "" + +#: laser_essentials/steps/raster_step.py +msgid "Distance between halftone dot centers" +msgstr "" + +#: laser_essentials/steps/raster_step.py +msgid "Halftone Angle" +msgstr "" + +#: laser_essentials/steps/raster_step.py +msgid "Rotation of the halftone dot grid" +msgstr "" + #: laser_essentials/steps/raster_step.py msgid "Cross-Hatch" msgstr "" @@ -472,14 +489,6 @@ msgid "" "Corrects X misalignment between left-to-right and right-to-left raster passes" msgstr "" -#: laser_essentials/steps/raster_step.py -msgid "Invert" -msgstr "" - -#: laser_essentials/steps/raster_step.py -msgid "Engrave white areas instead of black areas" -msgstr "" - #: laser_essentials/steps/raster_step.py msgid "Number of Depth Levels" msgstr "" @@ -774,27 +783,3 @@ msgstr "" #: laser_essentials/commands/material_test_cmd.py msgid "Add Material Test" msgstr "" - -#: laser_essentials/steps/raster_step.py -msgid "Serpentine" -msgstr "" - -#: laser_essentials/steps/raster_step.py -msgid "Alternate the dither direction on every row to avoid directional artifacts" -msgstr "" - -#: laser_essentials/steps/raster_step.py -msgid "Halftone Cell Size" -msgstr "" - -#: laser_essentials/steps/raster_step.py -msgid "Distance between halftone dot centers" -msgstr "" - -#: laser_essentials/steps/raster_step.py -msgid "Halftone Angle" -msgstr "" - -#: laser_essentials/steps/raster_step.py -msgid "Rotation of the halftone dot grid" -msgstr "" diff --git a/rayforge/builtin_addons/rayforge-addon-laser/locale/pt/LC_MESSAGES/laser_essentials.po b/rayforge/builtin_addons/rayforge-addon-laser/locale/pt/LC_MESSAGES/laser_essentials.po index 99d712321..117502f87 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/locale/pt/LC_MESSAGES/laser_essentials.po +++ b/rayforge/builtin_addons/rayforge-addon-laser/locale/pt/LC_MESSAGES/laser_essentials.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: Rayforge\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-24 21:41+0200\n" +"POT-Creation-Date: 2026-10-12 18:45+0200\n" "PO-Revision-Date: 2025-07-24 22:09+0200\n" "Last-Translator: Samuel Abels \n" "Language-Team: Portuguese \n" @@ -201,6 +201,10 @@ msgstr "Predefinições" msgid "Load common test configurations" msgstr "Carregar configurações de teste comuns" +#: laser_essentials/widgets/raster_power_widget.py +msgid "Min Power (White)" +msgstr "Potência Mín. (Branco)" + #: laser_essentials/widgets/raster_power_widget.py #: laser_essentials/steps/raster_step.py msgid "Power for lightest areas, as a percentage of the step's main power" @@ -208,6 +212,10 @@ msgstr "" "Potência para as áreas mais claras, como uma porcentagem da potência " "principal da etapa" +#: laser_essentials/widgets/raster_power_widget.py +msgid "Max Power (Black)" +msgstr "Potência Máx. (Preto)" + #: laser_essentials/widgets/raster_power_widget.py #: laser_essentials/steps/raster_step.py msgid "Power for darkest areas, as a percentage of the step's main power" @@ -215,22 +223,6 @@ msgstr "" "Potência para as áreas mais escuras, como uma porcentagem da potência " "principal da etapa" -#: laser_essentials/widgets/raster_power_widget.py -msgid "Min Power (Black)" -msgstr "Potência Mínima (Preto)" - -#: laser_essentials/widgets/raster_power_widget.py -msgid "Max Power (White)" -msgstr "Potência Máxima (Branco)" - -#: laser_essentials/widgets/raster_power_widget.py -msgid "Min Power (White)" -msgstr "Potência Mín. (Branco)" - -#: laser_essentials/widgets/raster_power_widget.py -msgid "Max Power (Black)" -msgstr "Potência Máx. (Preto)" - #: laser_essentials/widgets/raster_page.py msgid "Engrave Speed" msgstr "Velocidade de Gravação" @@ -419,6 +411,33 @@ msgstr "Método de Gravação" msgid "Algorithm for converting grayscale to binary" msgstr "Algoritmo para converter tons de cinza para binário" +#: laser_essentials/steps/raster_step.py +msgid "Serpentine" +msgstr "Serpentina" + +#: laser_essentials/steps/raster_step.py +msgid "" +"Alternate the dither direction on every row to avoid directional artifacts" +msgstr "" +"Alternar a direção do pontilhado em cada linha para evitar artefatos " +"direcionais" + +#: laser_essentials/steps/raster_step.py +msgid "Halftone Cell Size" +msgstr "Tamanho da célula de meio-tom" + +#: laser_essentials/steps/raster_step.py +msgid "Distance between halftone dot centers" +msgstr "Distância entre os centros dos pontos de meio-tom" + +#: laser_essentials/steps/raster_step.py +msgid "Halftone Angle" +msgstr "Ângulo do meio-tom" + +#: laser_essentials/steps/raster_step.py +msgid "Rotation of the halftone dot grid" +msgstr "Rotação da grade de pontos de meio-tom" + #: laser_essentials/steps/raster_step.py msgid "Cross-Hatch" msgstr "Hachura Cruzada" @@ -489,14 +508,6 @@ msgstr "" "Corrige o desalinhamento em X entre passadas de rasterização da esquerdapara " "a direita e da direita para a esquerda" -#: laser_essentials/steps/raster_step.py -msgid "Invert" -msgstr "Inverter" - -#: laser_essentials/steps/raster_step.py -msgid "Engrave white areas instead of black areas" -msgstr "Gravar áreas brancas em vez de áreas pretas" - #: laser_essentials/steps/raster_step.py msgid "Number of Depth Levels" msgstr "Número de Níveis de Profundidade" @@ -799,27 +810,3 @@ msgstr "Cabeça de Laser" #: laser_essentials/commands/material_test_cmd.py msgid "Add Material Test" msgstr "Adicionar Teste de Material" - -#: laser_essentials/steps/raster_step.py -msgid "Serpentine" -msgstr "Serpentina" - -#: laser_essentials/steps/raster_step.py -msgid "Alternate the dither direction on every row to avoid directional artifacts" -msgstr "Alternar a direção do pontilhado em cada linha para evitar artefatos direcionais" - -#: laser_essentials/steps/raster_step.py -msgid "Halftone Cell Size" -msgstr "Tamanho da célula de meio-tom" - -#: laser_essentials/steps/raster_step.py -msgid "Distance between halftone dot centers" -msgstr "Distância entre os centros dos pontos de meio-tom" - -#: laser_essentials/steps/raster_step.py -msgid "Halftone Angle" -msgstr "Ângulo do meio-tom" - -#: laser_essentials/steps/raster_step.py -msgid "Rotation of the halftone dot grid" -msgstr "Rotação da grade de pontos de meio-tom" diff --git a/rayforge/builtin_addons/rayforge-addon-laser/locale/uk/LC_MESSAGES/laser_essentials.po b/rayforge/builtin_addons/rayforge-addon-laser/locale/uk/LC_MESSAGES/laser_essentials.po index 4b80f351f..4e4923f3b 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/locale/uk/LC_MESSAGES/laser_essentials.po +++ b/rayforge/builtin_addons/rayforge-addon-laser/locale/uk/LC_MESSAGES/laser_essentials.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: Rayforge\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-24 21:41+0200\n" +"POT-Creation-Date: 2026-10-12 18:45+0200\n" "PO-Revision-Date: 2026-02-23 01:17+0100\n" "Last-Translator: Samuel Abels \n" "Language-Team: Ukrainian\n" @@ -202,6 +202,10 @@ msgstr "Пресети" msgid "Load common test configurations" msgstr "Завантажити типові конфігурації тестування" +#: laser_essentials/widgets/raster_power_widget.py +msgid "Min Power (White)" +msgstr "Мін. потужність (біле)" + #: laser_essentials/widgets/raster_power_widget.py #: laser_essentials/steps/raster_step.py msgid "Power for lightest areas, as a percentage of the step's main power" @@ -209,28 +213,16 @@ msgstr "" "Потужність для найсвітліших ділянок, у відсотках від основної потужності " "кроку" +#: laser_essentials/widgets/raster_power_widget.py +msgid "Max Power (Black)" +msgstr "Макс. потужність (чорне)" + #: laser_essentials/widgets/raster_power_widget.py #: laser_essentials/steps/raster_step.py msgid "Power for darkest areas, as a percentage of the step's main power" msgstr "" "Потужність для найтемніших ділянок, у відсотках від основної потужності кроку" -#: laser_essentials/widgets/raster_power_widget.py -msgid "Min Power (Black)" -msgstr "Мін. потужність (чорне)" - -#: laser_essentials/widgets/raster_power_widget.py -msgid "Max Power (White)" -msgstr "Макс. потужність (біле)" - -#: laser_essentials/widgets/raster_power_widget.py -msgid "Min Power (White)" -msgstr "Мін. потужність (біле)" - -#: laser_essentials/widgets/raster_power_widget.py -msgid "Max Power (Black)" -msgstr "Макс. потужність (чорне)" - #: laser_essentials/widgets/raster_page.py msgid "Engrave Speed" msgstr "Швидкість гравіювання" @@ -421,6 +413,33 @@ msgstr "Метод гравіювання" msgid "Algorithm for converting grayscale to binary" msgstr "Алгоритм перетворення відтінків сірого у двійковий формат" +#: laser_essentials/steps/raster_step.py +msgid "Serpentine" +msgstr "Змійкою" + +#: laser_essentials/steps/raster_step.py +msgid "" +"Alternate the dither direction on every row to avoid directional artifacts" +msgstr "" +"Змінювати напрямок дизерингу в кожному рядку, щоб уникнути спрямованих " +"артефактів" + +#: laser_essentials/steps/raster_step.py +msgid "Halftone Cell Size" +msgstr "Розмір комірки півтону" + +#: laser_essentials/steps/raster_step.py +msgid "Distance between halftone dot centers" +msgstr "Відстань між центрами півтонових точок" + +#: laser_essentials/steps/raster_step.py +msgid "Halftone Angle" +msgstr "Кут півтону" + +#: laser_essentials/steps/raster_step.py +msgid "Rotation of the halftone dot grid" +msgstr "Поворот сітки півтонових точок" + #: laser_essentials/steps/raster_step.py msgid "Cross-Hatch" msgstr "Перехресна штриховка" @@ -490,14 +509,6 @@ msgid "" msgstr "" "Виправляє зміщення по X між растрними проходами зліва направо та справаналіво" -#: laser_essentials/steps/raster_step.py -msgid "Invert" -msgstr "Інвертувати" - -#: laser_essentials/steps/raster_step.py -msgid "Engrave white areas instead of black areas" -msgstr "Гравіювати білі ділянки замість чорних" - #: laser_essentials/steps/raster_step.py msgid "Number of Depth Levels" msgstr "Кількість рівнів глибини" @@ -797,27 +808,3 @@ msgstr "Лазерна голівка" #: laser_essentials/commands/material_test_cmd.py msgid "Add Material Test" msgstr "Додати тест матеріалу" - -#: laser_essentials/steps/raster_step.py -msgid "Serpentine" -msgstr "Змійкою" - -#: laser_essentials/steps/raster_step.py -msgid "Alternate the dither direction on every row to avoid directional artifacts" -msgstr "Змінювати напрямок дизерингу в кожному рядку, щоб уникнути спрямованих артефактів" - -#: laser_essentials/steps/raster_step.py -msgid "Halftone Cell Size" -msgstr "Розмір комірки півтону" - -#: laser_essentials/steps/raster_step.py -msgid "Distance between halftone dot centers" -msgstr "Відстань між центрами півтонових точок" - -#: laser_essentials/steps/raster_step.py -msgid "Halftone Angle" -msgstr "Кут півтону" - -#: laser_essentials/steps/raster_step.py -msgid "Rotation of the halftone dot grid" -msgstr "Поворот сітки півтонових точок" diff --git a/rayforge/builtin_addons/rayforge-addon-laser/locale/zh_CN/LC_MESSAGES/laser_essentials.po b/rayforge/builtin_addons/rayforge-addon-laser/locale/zh_CN/LC_MESSAGES/laser_essentials.po index 02c088af7..0814a75df 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/locale/zh_CN/LC_MESSAGES/laser_essentials.po +++ b/rayforge/builtin_addons/rayforge-addon-laser/locale/zh_CN/LC_MESSAGES/laser_essentials.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: Rayforge\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-24 21:41+0200\n" +"POT-Creation-Date: 2026-10-12 18:45+0200\n" "PO-Revision-Date: 2025-07-13 11:49+0200\n" "Last-Translator: Samuel Abels \n" "Language-Team: Chinese (Simplified)\n" @@ -201,32 +201,24 @@ msgstr "预设" msgid "Load common test configurations" msgstr "加载常用测试配置" +#: laser_essentials/widgets/raster_power_widget.py +msgid "Min Power (White)" +msgstr "最小功率 (白)" + #: laser_essentials/widgets/raster_power_widget.py #: laser_essentials/steps/raster_step.py msgid "Power for lightest areas, as a percentage of the step's main power" msgstr "最亮区域的功率,占该步骤主功率的百分比" +#: laser_essentials/widgets/raster_power_widget.py +msgid "Max Power (Black)" +msgstr "最大功率 (黑)" + #: laser_essentials/widgets/raster_power_widget.py #: laser_essentials/steps/raster_step.py msgid "Power for darkest areas, as a percentage of the step's main power" msgstr "最暗区域的功率,占该步骤主功率的百分比" -#: laser_essentials/widgets/raster_power_widget.py -msgid "Min Power (Black)" -msgstr "最小功率 (黑)" - -#: laser_essentials/widgets/raster_power_widget.py -msgid "Max Power (White)" -msgstr "最大功率 (白)" - -#: laser_essentials/widgets/raster_power_widget.py -msgid "Min Power (White)" -msgstr "最小功率 (白)" - -#: laser_essentials/widgets/raster_power_widget.py -msgid "Max Power (Black)" -msgstr "最大功率 (黑)" - #: laser_essentials/widgets/raster_page.py msgid "Engrave Speed" msgstr "雕刻速度" @@ -410,6 +402,31 @@ msgstr "雕刻方法" msgid "Algorithm for converting grayscale to binary" msgstr "将灰度转换为二值的算法" +#: laser_essentials/steps/raster_step.py +msgid "Serpentine" +msgstr "蛇形扫描" + +#: laser_essentials/steps/raster_step.py +msgid "" +"Alternate the dither direction on every row to avoid directional artifacts" +msgstr "每行交替抖动方向,以避免方向性伪影" + +#: laser_essentials/steps/raster_step.py +msgid "Halftone Cell Size" +msgstr "半色调网格尺寸" + +#: laser_essentials/steps/raster_step.py +msgid "Distance between halftone dot centers" +msgstr "半色调网点中心之间的距离" + +#: laser_essentials/steps/raster_step.py +msgid "Halftone Angle" +msgstr "半色调角度" + +#: laser_essentials/steps/raster_step.py +msgid "Rotation of the halftone dot grid" +msgstr "半色调网点网格的旋转角度" + #: laser_essentials/steps/raster_step.py msgid "Cross-Hatch" msgstr "交叉填充" @@ -472,14 +489,6 @@ msgid "" "Corrects X misalignment between left-to-right and right-to-left raster passes" msgstr "校正从左到右与从右到左光栅通道之间的 X 错位" -#: laser_essentials/steps/raster_step.py -msgid "Invert" -msgstr "反转" - -#: laser_essentials/steps/raster_step.py -msgid "Engrave white areas instead of black areas" -msgstr "雕刻白色区域而不是黑色区域" - #: laser_essentials/steps/raster_step.py msgid "Number of Depth Levels" msgstr "深度层级数" @@ -776,27 +785,3 @@ msgstr "激光头" #: laser_essentials/commands/material_test_cmd.py msgid "Add Material Test" msgstr "添加材料测试" - -#: laser_essentials/steps/raster_step.py -msgid "Serpentine" -msgstr "蛇形扫描" - -#: laser_essentials/steps/raster_step.py -msgid "Alternate the dither direction on every row to avoid directional artifacts" -msgstr "每行交替抖动方向,以避免方向性伪影" - -#: laser_essentials/steps/raster_step.py -msgid "Halftone Cell Size" -msgstr "半色调网格尺寸" - -#: laser_essentials/steps/raster_step.py -msgid "Distance between halftone dot centers" -msgstr "半色调网点中心之间的距离" - -#: laser_essentials/steps/raster_step.py -msgid "Halftone Angle" -msgstr "半色调角度" - -#: laser_essentials/steps/raster_step.py -msgid "Rotation of the halftone dot grid" -msgstr "半色调网点网格的旋转角度" diff --git a/rayforge/locale/de/LC_MESSAGES/rayforge.po b/rayforge/locale/de/LC_MESSAGES/rayforge.po index a04568b3e..ebd57c3f0 100644 --- a/rayforge/locale/de/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/de/LC_MESSAGES/rayforge.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: Rayforge\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-10-06 22:41+0200\n" +"POT-Creation-Date: 2026-10-15 02:25+0200\n" "PO-Revision-Date: 2025-07-24 22:08+0200\n" "Last-Translator: Samuel Abels \n" "Language-Team: none\n" @@ -449,6 +449,18 @@ msgstr "" "Welche Verbindungsart verwendet werden soll. „Automatisch“ öffnet USB, wenn " "verfügbar, und greift andernfalls auf das Netzwerk zurück." +#: rayforge/machine/driver/ruidarpa/rpa_adapter.py +msgid "TCP protocol" +msgstr "TCP-Protokoll" + +#: rayforge/machine/driver/ruidarpa/rpa_adapter.py +msgid "" +"Use TCP instead of UDP for the network connection. Needed by controllers " +"that do not respond over UDP." +msgstr "" +"TCP statt UDP für die Netzwerkverbindung verwenden. Wird von Controllern " +"benötigt, die nicht über UDP antworten." + #: rayforge/machine/driver/ruidarpa/rpa_adapter.py #: rayforge/machine/driver/octoprint/octoprint_driver.py #: rayforge/machine/driver/grbl/grbl_telnet.py @@ -634,6 +646,14 @@ msgstr "" "Dieser Treiber ist experimentell und höchstwahrscheinlich fehlerhaft. Er " "funktioniert möglicherweise nicht zuverlässig. Verwendung auf eigenes Risiko." +#: rayforge/machine/driver/driver.py +msgid "Home Switch" +msgstr "Referenzschalter" + +#: rayforge/machine/driver/driver.py +msgid "Probe" +msgstr "Taster" + #: rayforge/machine/driver/driver.py rayforge/machine/transport/transport.py #: rayforge/ui_gtk/machine/machine_dropdown.py #: rayforge/ui_gtk/machine/status_widget.py @@ -2692,6 +2712,7 @@ msgstr "" #: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py #: rayforge/ui_gtk/doceditor/material_library_list.py #: rayforge/ui_gtk/doceditor/add_material_dialog.py +#: rayforge/ui_gtk/doceditor/path_tolerance_dialog.py #: rayforge/ui_gtk/doceditor/step_type_selection_dialog.py #: rayforge/ui_gtk/doceditor/material_selector.py #: rayforge/ui_gtk/doceditor/material_list.py @@ -2924,16 +2945,16 @@ msgstr "Erweiterungen installieren, aktualisieren und entfernen." #: rayforge/ui_gtk/settings/gesture_preferences_page.py msgid "Mouse Gestures" -msgstr "" +msgstr "Mausgesten" #: rayforge/ui_gtk/settings/gesture_preferences_page.py #, python-format msgid "Default: %s" -msgstr "" +msgstr "Standard: %s" #: rayforge/ui_gtk/settings/gesture_preferences_page.py msgid "Unassigned" -msgstr "" +msgstr "Nicht zugewiesen" #: rayforge/ui_gtk/settings/recipe_manager_page.py msgid "Recipes" @@ -3217,6 +3238,7 @@ msgid "Length" msgstr "Länge" #: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/machine/bidir_offset_table.py #: rayforge/ui_gtk/doceditor/bottom_panel.py msgid "Speed" msgstr "Geschwindigkeit" @@ -3254,6 +3276,16 @@ msgstr "Nach Updates suchen" msgid "Automatically check for new Rayforge versions on startup" msgstr "Automatisch beim Start nach neuen Rayforge-Versionen suchen" +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Watch imported files" +msgstr "Importierte Dateien überwachen" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Offer to reload imported files when they change on disk" +msgstr "" +"Anbieten, importierte Dateien neu zu laden, wenn sie sich auf der Festplatte " +"ändern" + #: rayforge/ui_gtk/settings/general_preferences_page.py msgid "Startup behavior" msgstr "Startverhalten" @@ -3433,10 +3465,42 @@ msgstr "Gruppieren" msgid "Ungroup" msgstr "Gruppierung aufheben" +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +msgid "Break Apart" +msgstr "Auseinanderbrechen" + +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +msgid "Close Paths…" +msgstr "Pfade schließen…" + +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +msgid "Join Open Paths…" +msgstr "Offene Pfade verbinden…" + +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +msgid "Delete Duplicates" +msgstr "Duplikate löschen" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +msgid "Clean Up Paths" +msgstr "Pfade bereinigen" + #: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/doceditor/stock_cmd.py msgid "Convert to Stock" msgstr "In Material umwandeln" +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +#: rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Reload from Disk" +msgstr "Von Festplatte neu laden" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +#: rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Relink Source File…" +msgstr "Quelldatei neu verknüpfen…" + #: rayforge/ui_gtk/canvas2d/context_menu.py msgid "Add Tab Here" msgstr "Haltesteg hier hinzufügen" @@ -3503,6 +3567,66 @@ msgstr "Material hinzufügen" msgid "Auto Layout (Simple)" msgstr "Automatische Anordnung (Einfach)" +#: rayforge/ui_gtk/actions.py +#, python-brace-format +msgid "The file of {name} cannot be found. Use Relink to pick it." +msgstr "" +"Die Datei von {name} kann nicht gefunden werden. Verwende „Neu verknüpfen“, " +"um sie auszuwählen." + +#: rayforge/ui_gtk/actions.py +msgid "Nothing to break apart" +msgstr "Nichts zum Auseinanderbrechen" + +#: rayforge/ui_gtk/actions.py +#, python-brace-format +msgid "Closed {count} path" +msgid_plural "Closed {count} paths" +msgstr[0] "{count} Pfad geschlossen" +msgstr[1] "{count} Pfade geschlossen" + +#: rayforge/ui_gtk/actions.py +msgid "Close Paths" +msgstr "Pfade schließen" + +#: rayforge/ui_gtk/actions.py +msgid "" +"Closes open paths whose start and end point are closer than the tolerance." +msgstr "" +"Schließt offene Pfade, deren Start- und Endpunkt näher als die Toleranz " +"beieinanderliegen." + +#: rayforge/ui_gtk/actions.py +#, python-brace-format +msgid "Made {count} join" +msgid_plural "Made {count} joins" +msgstr[0] "{count} Verbindung hergestellt" +msgstr[1] "{count} Verbindungen hergestellt" + +#: rayforge/ui_gtk/actions.py +msgid "Join Open Paths" +msgstr "Offene Pfade verbinden" + +#: rayforge/ui_gtk/actions.py +msgid "" +"Joins open paths whose end points are closer than the tolerance. Paths whose " +"ends then meet are closed." +msgstr "" +"Verbindet offene Pfade, deren Endpunkte näher als die Toleranz " +"beieinanderliegen. Pfade, deren Enden danach aufeinandertreffen, werden " +"geschlossen." + +#: rayforge/ui_gtk/actions.py +msgid "Join Paths" +msgstr "Pfade verbinden" + +#: rayforge/ui_gtk/actions.py +#, python-brace-format +msgid "Deleted {count} duplicate" +msgid_plural "Deleted {count} duplicates" +msgstr[0] "{count} Duplikat gelöscht" +msgstr[1] "{count} Duplikate gelöscht" + #: rayforge/ui_gtk/camera/lens_calibration_dialog.py #, python-brace-format msgid "{camera_name} - Lens Calibration" @@ -3598,6 +3722,7 @@ msgid "Temporal averaging, higher values cause trailing" msgstr "Zeitliche Mittelung, höhere Werte verursachen Schlieren" #: rayforge/ui_gtk/camera/image_settings_widget.py +#: rayforge/ui_gtk/camera/outside_view_widget.py msgid "Transparency" msgstr "Transparenz" @@ -4338,6 +4463,42 @@ msgstr "Kameras" msgid "Stream a camera image directly onto the work surface." msgstr "Ein Kamerabild direkt auf die Arbeitsfläche streamen." +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "View Outside Workspace" +msgstr "Ansicht außerhalb des Arbeitsbereichs" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "" +"Show the camera image in a margin around the work area to help position " +"workpieces. Requires image alignment." +msgstr "" +"Das Kamerabild in einem Rand um den Arbeitsbereich anzeigen, um Werkstücke " +"zu positionieren. Erfordert eine Bildausrichtung." + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Show Outside Workspace" +msgstr "Außerhalb des Arbeitsbereichs anzeigen" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Draw the camera image around the work area" +msgstr "Das Kamerabild um den Arbeitsbereich herum zeichnen" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Margin" +msgstr "Rand" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Size of the visible area on each side of the work area" +msgstr "Größe des sichtbaren Bereichs auf jeder Seite des Arbeitsbereichs" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Transparency outside the work area" +msgstr "Transparenz außerhalb des Arbeitsbereichs" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Not shown until the camera image is aligned" +msgstr "Wird erst angezeigt, wenn das Kamerabild ausgerichtet ist" + #: rayforge/ui_gtk/camera/alignment_widget.py msgid "" "Click the image to add reference points. Drag to move them.\n" @@ -5444,6 +5605,17 @@ msgstr "" "Steuerelemente für die Referenzfahrt einzelner Achsen im manuellen " "Steuerungsdialog aktivieren" +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Z Homing" +msgstr "Z-Referenzfahrt" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "" +"How the Z axis finds its reference: a home switch, or the auto-focus probe" +msgstr "" +"Wie die Z-Achse ihre Referenz findet: über einen Referenzschalter oder den " +"Autofokus-Taster" + #: rayforge/ui_gtk/machine/advanced_preferences_page.py msgid "Clear Alarm On Connect" msgstr "Alarm bei Verbindung löschen" @@ -5897,6 +6069,45 @@ msgstr "" "Diese Notizen werden mit dieser Maschine gespeichert und niemals durch " "Geräteprofil-Aktualisierungen überschrieben." +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "Offset" +msgstr "Versatz" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "Positive values shift right-to-left passes to the right" +msgstr "Positive Werte verschieben Rechts-nach-links-Durchläufe nach rechts" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "Remove Row" +msgstr "Zeile entfernen" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +#, python-brace-format +msgid "{speed}: {offset}" +msgstr "{speed}: {offset}" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "Bidirectional Scan Offset by Speed" +msgstr "Bidirektionaler Scan-Versatz nach Geschwindigkeit" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "" +"Shift of right-to-left raster passes at each engrave speed, interpolated " +"between rows. Engrave steps use it when their own Bidirectional Scan Offset " +"is 0." +msgstr "" +"Verschiebung der Rechts-nach-links-Rasterdurchläufe bei jeder " +"Graviergeschwindigkeit, zwischen den Zeilen interpoliert. Gravurschritte " +"verwenden sie, wenn ihr eigener bidirektionaler Scan-Versatz 0 ist." + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "Add Row" +msgstr "Zeile hinzufügen" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "No rows: all speeds use an offset of 0" +msgstr "Keine Zeilen: Alle Geschwindigkeiten verwenden einen Versatz von 0" + #: rayforge/ui_gtk/machine/wizard_pages/camera_page.py msgid "" "Optional. Configure any cameras you want to use for preview and alignment." @@ -7736,6 +7947,10 @@ msgstr "Zuletzt geöffnet" msgid "Import..." msgstr "Importieren..." +#: rayforge/ui_gtk/main_menu.py +msgid "Relink Source File..." +msgstr "Quelldatei neu verknüpfen..." + #: rayforge/ui_gtk/main_menu.py msgid "Export G-code..." msgstr "G-Code exportieren..." @@ -7931,7 +8146,7 @@ msgstr "Anordnen" msgid "_Tools" msgstr "_Werkzeuge" -#: rayforge/ui_gtk/main_menu.py +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/doceditor/frame_popover.py msgid "Frame" msgstr "Umrandung fahren" @@ -8175,79 +8390,79 @@ msgstr "Ein unerwarteter Fehler ist aufgetreten: {error}" #: rayforge/ui_gtk/gestures/model.py msgid "Click" -msgstr "" +msgstr "Klicken" #: rayforge/ui_gtk/gestures/model.py msgid "Double-click" -msgstr "" +msgstr "Doppelklick" #: rayforge/ui_gtk/gestures/model.py msgid "Drag" -msgstr "" +msgstr "Ziehen" #: rayforge/ui_gtk/gestures/model.py msgid "Scroll" -msgstr "" +msgstr "Scrollen" #: rayforge/ui_gtk/gestures/model.py msgid "Middle" -msgstr "" +msgstr "Mitte" #: rayforge/ui_gtk/gestures/model.py msgid "Shift" -msgstr "" +msgstr "Umschalt" #: rayforge/ui_gtk/gestures/model.py msgid "Ctrl" -msgstr "" +msgstr "Strg" #: rayforge/ui_gtk/gestures/model.py msgid "Alt" -msgstr "" +msgstr "Alt" #: rayforge/ui_gtk/gestures/builtin.py msgid "2D Canvas" -msgstr "" +msgstr "2D-Arbeitsfläche" #: rayforge/ui_gtk/gestures/builtin.py msgid "Navigation gestures on the 2D work surface." -msgstr "" +msgstr "Navigationsgesten auf der 2D-Arbeitsfläche." #: rayforge/ui_gtk/gestures/builtin.py msgid "3D Canvas" -msgstr "" +msgstr "3D-Arbeitsfläche" #: rayforge/ui_gtk/gestures/builtin.py msgid "Navigation gestures on the 3D preview." -msgstr "" +msgstr "Navigationsgesten in der 3D-Vorschau." #: rayforge/ui_gtk/gestures/builtin.py msgid "Pan the view" -msgstr "" +msgstr "Ansicht verschieben" #: rayforge/ui_gtk/gestures/builtin.py msgid "Hold the mouse button down and move to pan the view." -msgstr "" +msgstr "Maustaste gedrückt halten und bewegen, um die Ansicht zu verschieben." #: rayforge/ui_gtk/gestures/builtin.py msgid "Orbit the camera" -msgstr "" +msgstr "Kamera umkreisen" #: rayforge/ui_gtk/gestures/builtin.py msgid "Hold the mouse button down and move to orbit around the scene." -msgstr "" +msgstr "Maustaste gedrückt halten und bewegen, um die Szene zu umkreisen." #: rayforge/ui_gtk/gestures/builtin.py msgid "Pan the camera" -msgstr "" +msgstr "Kamera verschieben" #: rayforge/ui_gtk/gestures/builtin.py msgid "Hold the mouse button down and move to pan the scene." -msgstr "" +msgstr "Maustaste gedrückt halten und bewegen, um die Szene zu verschieben." #: rayforge/ui_gtk/gestures/builtin.py msgid "Rotate around the Z axis" -msgstr "" +msgstr "Um die Z-Achse drehen" #: rayforge/ui_gtk/project_cmd.py msgid "Unsaved Changes" @@ -8728,10 +8943,25 @@ msgstr "" "Fehler beim Erstellen der Bibliothek. Ein Ordner mit diesem Namen existiert " "möglicherweise bereits." +#: rayforge/ui_gtk/doceditor/frame_popover.py +msgid "Include Overscan Margin" +msgstr "Overscan-Marge einschließen" + +#: rayforge/ui_gtk/doceditor/frame_popover.py +msgid "Frame the full machine path, including the extra engraving travel" +msgstr "" +"Den vollständigen Maschinenpfad umrahmen, einschließlich des zusätzlichen " +"Gravierwegs" + #: rayforge/ui_gtk/doceditor/file_dialogs.py msgid "Open File" msgstr "Datei öffnen" +#: rayforge/ui_gtk/doceditor/file_dialogs.py +#, python-brace-format +msgid "Relink “{name}”" +msgstr "„{name}“ neu verknüpfen" + #: rayforge/ui_gtk/doceditor/file_dialogs.py msgid "All supported" msgstr "Alle unterstützten" @@ -8852,6 +9082,10 @@ msgstr "Benutzerdefiniert" msgid "Add New Layer" msgstr "Neue Ebene hinzufügen" +#: rayforge/ui_gtk/doceditor/path_tolerance_dialog.py +msgid "Tolerance" +msgstr "Toleranz" + #: rayforge/ui_gtk/doceditor/stock_properties_dialog.py #: rayforge/ui_gtk/doceditor/item_properties.py msgid "Stock Properties" @@ -8991,6 +9225,43 @@ msgstr "Maschine muss verbunden sein, um Hier Null zu setzen" msgid "Set current position as 0" msgstr "Aktuelle Position als 0 setzen" +#: rayforge/ui_gtk/doceditor/source_changed_dialog.py +msgid "Imported File Changed" +msgid_plural "Imported Files Changed" +msgstr[0] "Importierte Datei geändert" +msgstr[1] "Importierte Dateien geändert" + +#: rayforge/ui_gtk/doceditor/source_changed_dialog.py +#, python-brace-format +msgid "" +"\"{name}\" was changed on disk. Reload it? Position, size and settings are " +"kept." +msgstr "" +"„{name}“ wurde auf der Festplatte geändert. Neu laden? Position, Größe und " +"Einstellungen bleiben erhalten." + +#: rayforge/ui_gtk/doceditor/source_changed_dialog.py +msgid "" +"These imported files were changed on disk. Reload them? Position, size and " +"settings are kept." +msgstr "" +"Diese importierten Dateien wurden auf der Festplatte geändert. Neu laden? " +"Position, Größe und Einstellungen bleiben erhalten." + +#: rayforge/ui_gtk/doceditor/source_changed_dialog.py +msgid "_Ignore" +msgstr "_Ignorieren" + +#: rayforge/ui_gtk/doceditor/source_changed_dialog.py +msgid "_Reload" +msgstr "_Neu laden" + +#: rayforge/ui_gtk/doceditor/source_changed_dialog.py +msgid "Always reload this file without asking" +msgid_plural "Always reload the selected files without asking" +msgstr[0] "Diese Datei immer ohne Nachfrage neu laden" +msgstr[1] "Die ausgewählten Dateien immer ohne Nachfrage neu laden" + #: rayforge/ui_gtk/doceditor/step_type_selection_dialog.py msgid "Select Step Types" msgstr "Schritttypen auswählen" @@ -10229,6 +10500,77 @@ msgstr "Werkstücke neu anordnen" msgid "Reorder items" msgstr "Elemente neu anordnen" +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "{name} ({x:.1f}, {y:.1f} mm)" +msgstr "{name} ({x:.1f}, {y:.1f} mm)" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "Could not reload {name}: {error}" +msgstr "{name} konnte nicht neu geladen werden: {error}" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "Reloaded {name}" +msgstr "{name} neu geladen" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "{n} new element added" +msgid_plural "{n} new elements added" +msgstr[0] "{n} neues Element hinzugefügt" +msgstr[1] "{n} neue Elemente hinzugefügt" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "{n} element no longer in the file was removed" +msgid_plural "{n} elements no longer in the file were removed" +msgstr[0] "{n} nicht mehr in der Datei enthaltenes Element wurde entfernt" +msgstr[1] "{n} nicht mehr in der Datei enthaltene Elemente wurden entfernt" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "{n} edited element was left unchanged" +msgid_plural "{n} edited elements were left unchanged" +msgstr[0] "{n} bearbeitetes Element wurde unverändert gelassen" +msgstr[1] "{n} bearbeitete Elemente wurden unverändert gelassen" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "and {n} more" +msgstr "und {n} weitere" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "" +"{n} tab more than {limit:.1f} mm from the new outline was removed: {places}" +msgid_plural "" +"{n} tabs more than {limit:.1f} mm from the new outline were removed: {places}" +msgstr[0] "" +"{n} Haltesteg, der mehr als {limit:.1f} mm von der neuen Kontur entfernt " +"war, wurde entfernt: {places}" +msgstr[1] "" +"{n} Haltestege, die mehr als {limit:.1f} mm von der neuen Kontur entfernt " +"waren, wurden entfernt: {places}" + +#: rayforge/doceditor/reload_cmd.py +msgid "the file type is not known" +msgstr "der Dateityp ist unbekannt" + +#: rayforge/doceditor/reload_cmd.py +msgid "the file could not be read" +msgstr "die Datei konnte nicht gelesen werden" + +#: rayforge/doceditor/reload_cmd.py +msgid "the file contains nothing to import" +msgstr "die Datei enthält nichts zum Importieren" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "Reload {name}" +msgstr "{name} neu laden" + #: rayforge/doceditor/array_cmd.py msgid "Create Array" msgstr "Array erstellen" @@ -10237,6 +10579,22 @@ msgstr "Array erstellen" msgid "Create array copy" msgstr "Array-Kopie erstellen" +#: rayforge/doceditor/path_cleanup_cmd.py +msgid "Close paths" +msgstr "Pfade schließen" + +#: rayforge/doceditor/path_cleanup_cmd.py +msgid "Join paths" +msgstr "Pfade verbinden" + +#: rayforge/doceditor/path_cleanup_cmd.py +msgid "Delete duplicates" +msgstr "Duplikate löschen" + +#: rayforge/doceditor/path_cleanup_cmd.py +msgid "Break apart" +msgstr "Auseinanderbrechen" + #: rayforge/doceditor/editor.py #, python-brace-format msgid "{count} asset(s) require disabled addon '{addon}'" @@ -10314,11 +10672,28 @@ msgstr "An Punkt positionieren" msgid "Auto Layout" msgstr "Automatisches Layout" +#: rayforge/image/hpgl/importer.py +#, python-brace-format +msgid "Some HPGL instructions are not supported and were skipped: {names}" +msgstr "" +"Einige HPGL-Befehle werden nicht unterstützt und wurden übersprungen: {names}" + +#: rayforge/image/hpgl/importer.py +#, python-brace-format +msgid "{filename} contains no HPGL drawing commands." +msgstr "{filename} enthält keine HPGL-Zeichenbefehle." + +#: rayforge/image/hpgl/importer.py +#, python-brace-format +msgid "Pen {number}" +msgstr "Stift {number}" + #: rayforge/image/png/importer.py msgid "Failed to scan PNG file: {}" msgstr "Fehler beim Scannen der PNG-Datei: {}" -#: rayforge/image/png/importer.py rayforge/image/jpg/importer.py +#: rayforge/image/png/importer.py rayforge/image/bitmap/importer.py +#: rayforge/image/jpg/importer.py msgid "Failed to process image data." msgstr "Verarbeitung der Bilddaten fehlgeschlagen." @@ -10355,6 +10730,15 @@ msgstr "Rasterisierung des SVG für das Nachzeichnen fehlgeschlagen." msgid "Failed to normalize image data." msgstr "Normalisierung der Bilddaten fehlgeschlagen." +#: rayforge/image/svg/importer.py +msgid "This SVGZ file's gzip data could not be decompressed." +msgstr "" + +#: rayforge/image/bitmap/importer.py +#, python-brace-format +msgid "Could not read the {format} image: {error}" +msgstr "Das {format}-Bild konnte nicht gelesen werden: {error}" + #: rayforge/image/pdf/pdf_trace.py rayforge/image/pdf/pdf_vector.py msgid "PDF file contains no pages." msgstr "PDF-Datei enthält keine Seiten." @@ -10460,57 +10844,60 @@ msgid "Floyd Steinberg" msgstr "Floyd Steinberg" #: rayforge/image/dither.py -msgid "Bayer 2" -msgstr "Bayer 2" +msgid "Atkinson" +msgstr "Atkinson" #: rayforge/image/dither.py -msgid "Bayer 4" -msgstr "Bayer 4" +msgid "Stucki" +msgstr "Stucki" #: rayforge/image/dither.py -msgid "Bayer 8" -msgstr "Bayer 8" +msgid "Jarvis, Judice & Ninke" +msgstr "Jarvis, Judice & Ninke" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "View Outside Workspace" -msgstr "Ansicht außerhalb des Arbeitsbereichs" +#: rayforge/image/dither.py +msgid "Sierra" +msgstr "Sierra" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Show the camera image in a margin around the work area to help position workpieces. Requires image alignment." -msgstr "Das Kamerabild in einem Rand um den Arbeitsbereich anzeigen, um Werkstücke zu positionieren. Erfordert eine Bildausrichtung." +#: rayforge/image/dither.py +msgid "Sierra 2-Row" +msgstr "Sierra 2-Row" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Show Outside Workspace" -msgstr "Außerhalb des Arbeitsbereichs anzeigen" +#: rayforge/image/dither.py +msgid "Sierra Lite" +msgstr "Sierra Lite" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Draw the camera image around the work area" -msgstr "Das Kamerabild um den Arbeitsbereich herum zeichnen" +#: rayforge/image/dither.py +msgid "Burkes" +msgstr "Burkes" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Not shown until the camera image is aligned" -msgstr "Wird erst angezeigt, wenn das Kamerabild ausgerichtet ist" +#: rayforge/image/dither.py +msgid "Bayer 2" +msgstr "Bayer 2" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Margin" -msgstr "Rand" +#: rayforge/image/dither.py +msgid "Bayer 4" +msgstr "Bayer 4" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Size of the visible area on each side of the work area" -msgstr "Größe des sichtbaren Bereichs auf jeder Seite des Arbeitsbereichs" +#: rayforge/image/dither.py +msgid "Bayer 8" +msgstr "Bayer 8" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Transparency outside the work area" -msgstr "Transparenz außerhalb des Arbeitsbereichs" +#: rayforge/image/dither.py +msgid "Newsprint" +msgstr "Zeitungsraster" -#: rayforge/image/bitmap/importer.py -#, python-brace-format -msgid "Could not read the {format} image: {error}" -msgstr "Das {format}-Bild konnte nicht gelesen werden: {error}" +#: rayforge/image/dither.py +msgid "Halftone" +msgstr "Halbton" #: rayforge/image/postscript/importer.py -msgid "Importing EPS and PostScript files requires Ghostscript, which was not found. Install Ghostscript and try again." -msgstr "Für den Import von EPS- und PostScript-Dateien wird Ghostscript benötigt, das nicht gefunden wurde. Installiere Ghostscript und versuche es erneut." +msgid "" +"Importing EPS and PostScript files requires Ghostscript, which was not " +"found. Install Ghostscript and try again." +msgstr "" +"Für den Import von EPS- und PostScript-Dateien wird Ghostscript benötigt, " +"das nicht gefunden wurde. Installiere Ghostscript und versuche es erneut." #: rayforge/image/postscript/importer.py #, python-brace-format @@ -10518,47 +10905,12 @@ msgid "Ghostscript could not convert {filename}: {error}" msgstr "Ghostscript konnte {filename} nicht umwandeln: {error}" #: rayforge/image/postscript/importer.py -msgid "This Illustrator file was saved without PDF compatibility. Rayforge can only read it with Ghostscript, which was not found. Install Ghostscript, or save the file again in Illustrator with \"Create PDF Compatible File\" enabled." -msgstr "Diese Illustrator-Datei wurde ohne PDF-Kompatibilität gespeichert. Rayforge kann sie nur mit Ghostscript lesen, das nicht gefunden wurde. Installiere Ghostscript oder speichere die Datei in Illustrator erneut mit aktivierter Option „PDF-kompatible Datei erstellen“." - -#: rayforge/image/hpgl/importer.py -#, python-brace-format -msgid "Some HPGL instructions are not supported and were skipped: {names}" -msgstr "Einige HPGL-Befehle werden nicht unterstützt und wurden übersprungen: {names}" - -#: rayforge/image/hpgl/importer.py -#, python-brace-format -msgid "{filename} contains no HPGL drawing commands." -msgstr "{filename} enthält keine HPGL-Zeichenbefehle." - -#: rayforge/image/hpgl/importer.py -#, python-brace-format -msgid "Pen {number}" -msgstr "Stift {number}" - -#: rayforge/machine/driver/driver.py -msgid "Home Switch" -msgstr "Referenzschalter" - -#: rayforge/machine/driver/driver.py -msgid "Probe" -msgstr "Taster" - -#: rayforge/ui_gtk/machine/advanced_preferences_page.py -msgid "Z Homing" -msgstr "Z-Referenzfahrt" - -#: rayforge/ui_gtk/machine/advanced_preferences_page.py msgid "" -"How the Z axis finds its reference: a home switch, " -"or the auto-focus probe" -msgstr "Wie die Z-Achse ihre Referenz findet: über einen Referenzschalter oder den Autofokus-Taster" - -#: rayforge/ui_gtk/doceditor/frame_popover.py -msgid "Include Overscan Margin" -msgstr "Overscan-Marge einschließen" - -#: rayforge/ui_gtk/doceditor/frame_popover.py -msgid "" -"Frame the full machine path, including the extra engraving travel" -msgstr "Den vollständigen Maschinenpfad umrahmen, einschließlich des zusätzlichen Gravierwegs" +"This Illustrator file was saved without PDF compatibility. Rayforge can only " +"read it with Ghostscript, which was not found. Install Ghostscript, or save " +"the file again in Illustrator with \"Create PDF Compatible File\" enabled." +msgstr "" +"Diese Illustrator-Datei wurde ohne PDF-Kompatibilität gespeichert. Rayforge " +"kann sie nur mit Ghostscript lesen, das nicht gefunden wurde. Installiere " +"Ghostscript oder speichere die Datei in Illustrator erneut mit aktivierter " +"Option „PDF-kompatible Datei erstellen“." diff --git a/rayforge/locale/en/LC_MESSAGES/rayforge.po b/rayforge/locale/en/LC_MESSAGES/rayforge.po index fcdf7e9b3..b1fab54e3 100644 --- a/rayforge/locale/en/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/en/LC_MESSAGES/rayforge.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-10-06 22:41+0200\n" +"POT-Creation-Date: 2026-10-15 02:25+0200\n" "PO-Revision-Date: 2025-07-13 11:49+0200\n" "Last-Translator: Samuel \n" "Language-Team: English\n" @@ -435,6 +435,16 @@ msgid "" "the network." msgstr "" +#: rayforge/machine/driver/ruidarpa/rpa_adapter.py +msgid "TCP protocol" +msgstr "" + +#: rayforge/machine/driver/ruidarpa/rpa_adapter.py +msgid "" +"Use TCP instead of UDP for the network connection. Needed by controllers " +"that do not respond over UDP." +msgstr "" + #: rayforge/machine/driver/ruidarpa/rpa_adapter.py #: rayforge/machine/driver/octoprint/octoprint_driver.py #: rayforge/machine/driver/grbl/grbl_telnet.py @@ -587,6 +597,14 @@ msgid "" "reliably. Use it at your own risk." msgstr "" +#: rayforge/machine/driver/driver.py +msgid "Home Switch" +msgstr "" + +#: rayforge/machine/driver/driver.py +msgid "Probe" +msgstr "" + #: rayforge/machine/driver/driver.py rayforge/machine/transport/transport.py #: rayforge/ui_gtk/machine/machine_dropdown.py #: rayforge/ui_gtk/machine/status_widget.py @@ -2452,6 +2470,7 @@ msgstr "" #: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py #: rayforge/ui_gtk/doceditor/material_library_list.py #: rayforge/ui_gtk/doceditor/add_material_dialog.py +#: rayforge/ui_gtk/doceditor/path_tolerance_dialog.py #: rayforge/ui_gtk/doceditor/step_type_selection_dialog.py #: rayforge/ui_gtk/doceditor/material_selector.py #: rayforge/ui_gtk/doceditor/material_list.py @@ -2954,6 +2973,7 @@ msgid "Length" msgstr "" #: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/machine/bidir_offset_table.py #: rayforge/ui_gtk/doceditor/bottom_panel.py msgid "Speed" msgstr "" @@ -2988,6 +3008,14 @@ msgstr "" msgid "Automatically check for new Rayforge versions on startup" msgstr "" +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Watch imported files" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Offer to reload imported files when they change on disk" +msgstr "" + #: rayforge/ui_gtk/settings/general_preferences_page.py msgid "Startup behavior" msgstr "" @@ -3161,10 +3189,42 @@ msgstr "" msgid "Ungroup" msgstr "" +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +msgid "Break Apart" +msgstr "" + +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +msgid "Close Paths…" +msgstr "" + +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +msgid "Join Open Paths…" +msgstr "" + +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +msgid "Delete Duplicates" +msgstr "" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +msgid "Clean Up Paths" +msgstr "" + #: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/doceditor/stock_cmd.py msgid "Convert to Stock" msgstr "" +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +#: rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Reload from Disk" +msgstr "" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +#: rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Relink Source File…" +msgstr "" + #: rayforge/ui_gtk/canvas2d/context_menu.py msgid "Add Tab Here" msgstr "" @@ -3231,6 +3291,59 @@ msgstr "" msgid "Auto Layout (Simple)" msgstr "" +#: rayforge/ui_gtk/actions.py +#, python-brace-format +msgid "The file of {name} cannot be found. Use Relink to pick it." +msgstr "" + +#: rayforge/ui_gtk/actions.py +msgid "Nothing to break apart" +msgstr "" + +#: rayforge/ui_gtk/actions.py +#, python-brace-format +msgid "Closed {count} path" +msgid_plural "Closed {count} paths" +msgstr[0] "" +msgstr[1] "" + +#: rayforge/ui_gtk/actions.py +msgid "Close Paths" +msgstr "" + +#: rayforge/ui_gtk/actions.py +msgid "" +"Closes open paths whose start and end point are closer than the tolerance." +msgstr "" + +#: rayforge/ui_gtk/actions.py +#, python-brace-format +msgid "Made {count} join" +msgid_plural "Made {count} joins" +msgstr[0] "" +msgstr[1] "" + +#: rayforge/ui_gtk/actions.py +msgid "Join Open Paths" +msgstr "" + +#: rayforge/ui_gtk/actions.py +msgid "" +"Joins open paths whose end points are closer than the tolerance. Paths whose " +"ends then meet are closed." +msgstr "" + +#: rayforge/ui_gtk/actions.py +msgid "Join Paths" +msgstr "" + +#: rayforge/ui_gtk/actions.py +#, python-brace-format +msgid "Deleted {count} duplicate" +msgid_plural "Deleted {count} duplicates" +msgstr[0] "" +msgstr[1] "" + #: rayforge/ui_gtk/camera/lens_calibration_dialog.py #, python-brace-format msgid "{camera_name} - Lens Calibration" @@ -3321,6 +3434,7 @@ msgid "Temporal averaging, higher values cause trailing" msgstr "" #: rayforge/ui_gtk/camera/image_settings_widget.py +#: rayforge/ui_gtk/camera/outside_view_widget.py msgid "Transparency" msgstr "" @@ -4030,6 +4144,40 @@ msgstr "" msgid "Stream a camera image directly onto the work surface." msgstr "" +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "View Outside Workspace" +msgstr "" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "" +"Show the camera image in a margin around the work area to help position " +"workpieces. Requires image alignment." +msgstr "" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Show Outside Workspace" +msgstr "" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Draw the camera image around the work area" +msgstr "" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Margin" +msgstr "" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Size of the visible area on each side of the work area" +msgstr "" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Transparency outside the work area" +msgstr "" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Not shown until the camera image is aligned" +msgstr "" + #: rayforge/ui_gtk/camera/alignment_widget.py msgid "" "Click the image to add reference points. Drag to move them.\n" @@ -5050,6 +5198,15 @@ msgstr "" msgid "Enable individual axis homing controls in the jog dialog" msgstr "" +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Z Homing" +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "" +"How the Z axis finds its reference: a home switch, or the auto-focus probe" +msgstr "" + #: rayforge/ui_gtk/machine/advanced_preferences_page.py msgid "Clear Alarm On Connect" msgstr "" @@ -5485,6 +5642,42 @@ msgid "" "profile updates." msgstr "" +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "Offset" +msgstr "" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "Positive values shift right-to-left passes to the right" +msgstr "" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "Remove Row" +msgstr "" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +#, python-brace-format +msgid "{speed}: {offset}" +msgstr "" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "Bidirectional Scan Offset by Speed" +msgstr "" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "" +"Shift of right-to-left raster passes at each engrave speed, interpolated " +"between rows. Engrave steps use it when their own Bidirectional Scan Offset " +"is 0." +msgstr "" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "Add Row" +msgstr "" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "No rows: all speeds use an offset of 0" +msgstr "" + #: rayforge/ui_gtk/machine/wizard_pages/camera_page.py msgid "" "Optional. Configure any cameras you want to use for preview and alignment." @@ -7199,6 +7392,10 @@ msgstr "" msgid "Import..." msgstr "" +#: rayforge/ui_gtk/main_menu.py +msgid "Relink Source File..." +msgstr "" + #: rayforge/ui_gtk/main_menu.py msgid "Export G-code..." msgstr "" @@ -7394,7 +7591,7 @@ msgstr "" msgid "_Tools" msgstr "" -#: rayforge/ui_gtk/main_menu.py +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/doceditor/frame_popover.py msgid "Frame" msgstr "" @@ -8151,10 +8348,23 @@ msgstr "" msgid "Failed to create library. A folder with that name may already exist." msgstr "" +#: rayforge/ui_gtk/doceditor/frame_popover.py +msgid "Include Overscan Margin" +msgstr "" + +#: rayforge/ui_gtk/doceditor/frame_popover.py +msgid "Frame the full machine path, including the extra engraving travel" +msgstr "" + #: rayforge/ui_gtk/doceditor/file_dialogs.py msgid "Open File" msgstr "" +#: rayforge/ui_gtk/doceditor/file_dialogs.py +#, python-brace-format +msgid "Relink “{name}”" +msgstr "" + #: rayforge/ui_gtk/doceditor/file_dialogs.py msgid "All supported" msgstr "" @@ -8275,6 +8485,10 @@ msgstr "" msgid "Add New Layer" msgstr "" +#: rayforge/ui_gtk/doceditor/path_tolerance_dialog.py +msgid "Tolerance" +msgstr "" + #: rayforge/ui_gtk/doceditor/stock_properties_dialog.py #: rayforge/ui_gtk/doceditor/item_properties.py msgid "Stock Properties" @@ -8410,6 +8624,39 @@ msgstr "" msgid "Set current position as 0" msgstr "" +#: rayforge/ui_gtk/doceditor/source_changed_dialog.py +msgid "Imported File Changed" +msgid_plural "Imported Files Changed" +msgstr[0] "" +msgstr[1] "" + +#: rayforge/ui_gtk/doceditor/source_changed_dialog.py +#, python-brace-format +msgid "" +"\"{name}\" was changed on disk. Reload it? Position, size and settings are " +"kept." +msgstr "" + +#: rayforge/ui_gtk/doceditor/source_changed_dialog.py +msgid "" +"These imported files were changed on disk. Reload them? Position, size and " +"settings are kept." +msgstr "" + +#: rayforge/ui_gtk/doceditor/source_changed_dialog.py +msgid "_Ignore" +msgstr "" + +#: rayforge/ui_gtk/doceditor/source_changed_dialog.py +msgid "_Reload" +msgstr "" + +#: rayforge/ui_gtk/doceditor/source_changed_dialog.py +msgid "Always reload this file without asking" +msgid_plural "Always reload the selected files without asking" +msgstr[0] "" +msgstr[1] "" + #: rayforge/ui_gtk/doceditor/step_type_selection_dialog.py msgid "Select Step Types" msgstr "" @@ -9588,6 +9835,73 @@ msgstr "" msgid "Reorder items" msgstr "" +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "{name} ({x:.1f}, {y:.1f} mm)" +msgstr "" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "Could not reload {name}: {error}" +msgstr "" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "Reloaded {name}" +msgstr "" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "{n} new element added" +msgid_plural "{n} new elements added" +msgstr[0] "" +msgstr[1] "" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "{n} element no longer in the file was removed" +msgid_plural "{n} elements no longer in the file were removed" +msgstr[0] "" +msgstr[1] "" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "{n} edited element was left unchanged" +msgid_plural "{n} edited elements were left unchanged" +msgstr[0] "" +msgstr[1] "" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "and {n} more" +msgstr "" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "" +"{n} tab more than {limit:.1f} mm from the new outline was removed: {places}" +msgid_plural "" +"{n} tabs more than {limit:.1f} mm from the new outline were removed: {places}" +msgstr[0] "" +msgstr[1] "" + +#: rayforge/doceditor/reload_cmd.py +msgid "the file type is not known" +msgstr "" + +#: rayforge/doceditor/reload_cmd.py +msgid "the file could not be read" +msgstr "" + +#: rayforge/doceditor/reload_cmd.py +msgid "the file contains nothing to import" +msgstr "" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "Reload {name}" +msgstr "" + #: rayforge/doceditor/array_cmd.py msgid "Create Array" msgstr "" @@ -9596,6 +9910,22 @@ msgstr "" msgid "Create array copy" msgstr "" +#: rayforge/doceditor/path_cleanup_cmd.py +msgid "Close paths" +msgstr "" + +#: rayforge/doceditor/path_cleanup_cmd.py +msgid "Join paths" +msgstr "" + +#: rayforge/doceditor/path_cleanup_cmd.py +msgid "Delete duplicates" +msgstr "" + +#: rayforge/doceditor/path_cleanup_cmd.py +msgid "Break apart" +msgstr "" + #: rayforge/doceditor/editor.py #, python-brace-format msgid "{count} asset(s) require disabled addon '{addon}'" @@ -9673,11 +10003,27 @@ msgstr "" msgid "Auto Layout" msgstr "" +#: rayforge/image/hpgl/importer.py +#, python-brace-format +msgid "Some HPGL instructions are not supported and were skipped: {names}" +msgstr "" + +#: rayforge/image/hpgl/importer.py +#, python-brace-format +msgid "{filename} contains no HPGL drawing commands." +msgstr "" + +#: rayforge/image/hpgl/importer.py +#, python-brace-format +msgid "Pen {number}" +msgstr "" + #: rayforge/image/png/importer.py msgid "Failed to scan PNG file: {}" msgstr "" -#: rayforge/image/png/importer.py rayforge/image/jpg/importer.py +#: rayforge/image/png/importer.py rayforge/image/bitmap/importer.py +#: rayforge/image/jpg/importer.py msgid "Failed to process image data." msgstr "" @@ -9713,6 +10059,15 @@ msgstr "" msgid "Failed to normalize image data." msgstr "" +#: rayforge/image/svg/importer.py +msgid "This SVGZ file's gzip data could not be decompressed." +msgstr "" + +#: rayforge/image/bitmap/importer.py +#, python-brace-format +msgid "Could not read the {format} image: {error}" +msgstr "" + #: rayforge/image/pdf/pdf_trace.py rayforge/image/pdf/pdf_vector.py msgid "PDF file contains no pages." msgstr "" @@ -9818,56 +10173,57 @@ msgid "Floyd Steinberg" msgstr "" #: rayforge/image/dither.py -msgid "Bayer 2" +msgid "Atkinson" msgstr "" #: rayforge/image/dither.py -msgid "Bayer 4" +msgid "Stucki" msgstr "" #: rayforge/image/dither.py -msgid "Bayer 8" +msgid "Jarvis, Judice & Ninke" msgstr "" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "View Outside Workspace" +#: rayforge/image/dither.py +msgid "Sierra" msgstr "" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Show the camera image in a margin around the work area to help position workpieces. Requires image alignment." +#: rayforge/image/dither.py +msgid "Sierra 2-Row" msgstr "" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Show Outside Workspace" +#: rayforge/image/dither.py +msgid "Sierra Lite" msgstr "" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Draw the camera image around the work area" +#: rayforge/image/dither.py +msgid "Burkes" msgstr "" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Not shown until the camera image is aligned" +#: rayforge/image/dither.py +msgid "Bayer 2" msgstr "" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Margin" +#: rayforge/image/dither.py +msgid "Bayer 4" msgstr "" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Size of the visible area on each side of the work area" +#: rayforge/image/dither.py +msgid "Bayer 8" msgstr "" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Transparency outside the work area" +#: rayforge/image/dither.py +msgid "Newsprint" msgstr "" -#: rayforge/image/bitmap/importer.py -#, python-brace-format -msgid "Could not read the {format} image: {error}" +#: rayforge/image/dither.py +msgid "Halftone" msgstr "" #: rayforge/image/postscript/importer.py -msgid "Importing EPS and PostScript files requires Ghostscript, which was not found. Install Ghostscript and try again." +msgid "" +"Importing EPS and PostScript files requires Ghostscript, which was not " +"found. Install Ghostscript and try again." msgstr "" #: rayforge/image/postscript/importer.py @@ -9876,47 +10232,8 @@ msgid "Ghostscript could not convert {filename}: {error}" msgstr "" #: rayforge/image/postscript/importer.py -msgid "This Illustrator file was saved without PDF compatibility. Rayforge can only read it with Ghostscript, which was not found. Install Ghostscript, or save the file again in Illustrator with \"Create PDF Compatible File\" enabled." -msgstr "" - -#: rayforge/image/hpgl/importer.py -#, python-brace-format -msgid "Some HPGL instructions are not supported and were skipped: {names}" -msgstr "" - -#: rayforge/image/hpgl/importer.py -#, python-brace-format -msgid "{filename} contains no HPGL drawing commands." -msgstr "" - -#: rayforge/image/hpgl/importer.py -#, python-brace-format -msgid "Pen {number}" -msgstr "" - -#: rayforge/machine/driver/driver.py -msgid "Home Switch" -msgstr "" - -#: rayforge/machine/driver/driver.py -msgid "Probe" -msgstr "" - -#: rayforge/ui_gtk/machine/advanced_preferences_page.py -msgid "Z Homing" -msgstr "" - -#: rayforge/ui_gtk/machine/advanced_preferences_page.py -msgid "" -"How the Z axis finds its reference: a home switch, " -"or the auto-focus probe" -msgstr "" - -#: rayforge/ui_gtk/doceditor/frame_popover.py -msgid "Include Overscan Margin" -msgstr "" - -#: rayforge/ui_gtk/doceditor/frame_popover.py msgid "" -"Frame the full machine path, including the extra engraving travel" +"This Illustrator file was saved without PDF compatibility. Rayforge can only " +"read it with Ghostscript, which was not found. Install Ghostscript, or save " +"the file again in Illustrator with \"Create PDF Compatible File\" enabled." msgstr "" diff --git a/rayforge/locale/es/LC_MESSAGES/rayforge.po b/rayforge/locale/es/LC_MESSAGES/rayforge.po index 47ad05aff..30cbb2a97 100644 --- a/rayforge/locale/es/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/es/LC_MESSAGES/rayforge.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: Rayforge\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-10-06 22:41+0200\n" +"POT-Creation-Date: 2026-10-15 02:25+0200\n" "PO-Revision-Date: 2025-08-08 10:00+0200\n" "Last-Translator: Samuel Abels\n" "Language-Team: Spanish \n" @@ -443,6 +443,18 @@ msgstr "" "Qué transporte utilizar. Automático abre USB cuando está disponible y " "recurre a la red." +#: rayforge/machine/driver/ruidarpa/rpa_adapter.py +msgid "TCP protocol" +msgstr "Protocolo TCP" + +#: rayforge/machine/driver/ruidarpa/rpa_adapter.py +msgid "" +"Use TCP instead of UDP for the network connection. Needed by controllers " +"that do not respond over UDP." +msgstr "" +"Usar TCP en lugar de UDP para la conexión de red. Lo necesitan los " +"controladores que no responden por UDP." + #: rayforge/machine/driver/ruidarpa/rpa_adapter.py #: rayforge/machine/driver/octoprint/octoprint_driver.py #: rayforge/machine/driver/grbl/grbl_telnet.py @@ -628,6 +640,14 @@ msgstr "" "Este controlador es experimental y casi con seguridad tiene errores. Puede " "que no funcione de forma fiable. Úselo bajo su propia responsabilidad." +#: rayforge/machine/driver/driver.py +msgid "Home Switch" +msgstr "Interruptor de origen" + +#: rayforge/machine/driver/driver.py +msgid "Probe" +msgstr "Sonda" + #: rayforge/machine/driver/driver.py rayforge/machine/transport/transport.py #: rayforge/ui_gtk/machine/machine_dropdown.py #: rayforge/ui_gtk/machine/status_widget.py @@ -2681,6 +2701,7 @@ msgstr "" #: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py #: rayforge/ui_gtk/doceditor/material_library_list.py #: rayforge/ui_gtk/doceditor/add_material_dialog.py +#: rayforge/ui_gtk/doceditor/path_tolerance_dialog.py #: rayforge/ui_gtk/doceditor/step_type_selection_dialog.py #: rayforge/ui_gtk/doceditor/material_selector.py #: rayforge/ui_gtk/doceditor/material_list.py @@ -2913,16 +2934,16 @@ msgstr "Instalar, actualizar y eliminar complementos." #: rayforge/ui_gtk/settings/gesture_preferences_page.py msgid "Mouse Gestures" -msgstr "" +msgstr "Gestos de ratón" #: rayforge/ui_gtk/settings/gesture_preferences_page.py #, python-format msgid "Default: %s" -msgstr "" +msgstr "Predeterminado: %s" #: rayforge/ui_gtk/settings/gesture_preferences_page.py msgid "Unassigned" -msgstr "" +msgstr "Sin asignar" #: rayforge/ui_gtk/settings/recipe_manager_page.py msgid "Recipes" @@ -3202,6 +3223,7 @@ msgid "Length" msgstr "Longitud" #: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/machine/bidir_offset_table.py #: rayforge/ui_gtk/doceditor/bottom_panel.py msgid "Speed" msgstr "Velocidad" @@ -3240,6 +3262,14 @@ msgstr "Buscar actualizaciones" msgid "Automatically check for new Rayforge versions on startup" msgstr "Buscar automáticamente nuevas versiones de Rayforge al inicio" +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Watch imported files" +msgstr "Vigilar archivos importados" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Offer to reload imported files when they change on disk" +msgstr "Ofrecer recargar los archivos importados cuando cambien en el disco" + #: rayforge/ui_gtk/settings/general_preferences_page.py msgid "Startup behavior" msgstr "Comportamiento de inicio" @@ -3420,10 +3450,42 @@ msgstr "Agrupar" msgid "Ungroup" msgstr "Desagrupar" +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +msgid "Break Apart" +msgstr "Separar" + +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +msgid "Close Paths…" +msgstr "Cerrar trazados…" + +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +msgid "Join Open Paths…" +msgstr "Unir trazados abiertos…" + +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +msgid "Delete Duplicates" +msgstr "Eliminar duplicados" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +msgid "Clean Up Paths" +msgstr "Limpiar trazados" + #: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/doceditor/stock_cmd.py msgid "Convert to Stock" msgstr "Convertir en material" +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +#: rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Reload from Disk" +msgstr "Recargar desde el disco" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +#: rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Relink Source File…" +msgstr "Volver a vincular archivo de origen…" + #: rayforge/ui_gtk/canvas2d/context_menu.py msgid "Add Tab Here" msgstr "Añadir pestaña de sujeción aquí" @@ -3490,6 +3552,65 @@ msgstr "Añadir material de base" msgid "Auto Layout (Simple)" msgstr "Disposición automática (Simple)" +#: rayforge/ui_gtk/actions.py +#, python-brace-format +msgid "The file of {name} cannot be found. Use Relink to pick it." +msgstr "" +"No se encuentra el archivo de {name}. Usa «Volver a vincular» para " +"seleccionarlo." + +#: rayforge/ui_gtk/actions.py +msgid "Nothing to break apart" +msgstr "Nada que separar" + +#: rayforge/ui_gtk/actions.py +#, python-brace-format +msgid "Closed {count} path" +msgid_plural "Closed {count} paths" +msgstr[0] "{count} trazado cerrado" +msgstr[1] "{count} trazados cerrados" + +#: rayforge/ui_gtk/actions.py +msgid "Close Paths" +msgstr "Cerrar trazados" + +#: rayforge/ui_gtk/actions.py +msgid "" +"Closes open paths whose start and end point are closer than the tolerance." +msgstr "" +"Cierra los trazados abiertos cuyo punto inicial y final estén más cerca que " +"la tolerancia." + +#: rayforge/ui_gtk/actions.py +#, python-brace-format +msgid "Made {count} join" +msgid_plural "Made {count} joins" +msgstr[0] "{count} unión realizada" +msgstr[1] "{count} uniones realizadas" + +#: rayforge/ui_gtk/actions.py +msgid "Join Open Paths" +msgstr "Unir trazados abiertos" + +#: rayforge/ui_gtk/actions.py +msgid "" +"Joins open paths whose end points are closer than the tolerance. Paths whose " +"ends then meet are closed." +msgstr "" +"Une los trazados abiertos cuyos puntos finales estén más cerca que la " +"tolerancia. Los trazados cuyos extremos se encuentren se cierran." + +#: rayforge/ui_gtk/actions.py +msgid "Join Paths" +msgstr "Unir trazados" + +#: rayforge/ui_gtk/actions.py +#, python-brace-format +msgid "Deleted {count} duplicate" +msgid_plural "Deleted {count} duplicates" +msgstr[0] "{count} duplicado eliminado" +msgstr[1] "{count} duplicados eliminados" + #: rayforge/ui_gtk/camera/lens_calibration_dialog.py #, python-brace-format msgid "{camera_name} - Lens Calibration" @@ -3585,6 +3706,7 @@ msgid "Temporal averaging, higher values cause trailing" msgstr "Promedio temporal, valores altos causan arrastre" #: rayforge/ui_gtk/camera/image_settings_widget.py +#: rayforge/ui_gtk/camera/outside_view_widget.py msgid "Transparency" msgstr "Transparencia" @@ -4325,6 +4447,42 @@ msgstr "" "Transmitir una imagen de la cámara directamente sobre la superficie de " "trabajo." +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "View Outside Workspace" +msgstr "Vista fuera del área de trabajo" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "" +"Show the camera image in a margin around the work area to help position " +"workpieces. Requires image alignment." +msgstr "" +"Muestra la imagen de la cámara en un margen alrededor del área de trabajo " +"para ayudar a posicionar las piezas. Requiere la alineación de la imagen." + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Show Outside Workspace" +msgstr "Mostrar fuera del área de trabajo" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Draw the camera image around the work area" +msgstr "Dibujar la imagen de la cámara alrededor del área de trabajo" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Margin" +msgstr "Margen" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Size of the visible area on each side of the work area" +msgstr "Tamaño del área visible a cada lado del área de trabajo" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Transparency outside the work area" +msgstr "Transparencia fuera del área de trabajo" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Not shown until the camera image is aligned" +msgstr "No se muestra hasta que la imagen de la cámara esté alineada" + #: rayforge/ui_gtk/camera/alignment_widget.py msgid "" "Click the image to add reference points. Drag to move them.\n" @@ -5435,6 +5593,17 @@ msgstr "" "Habilitar controles de referenciado de ejes individuales en el diálogo de " "jog." +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Z Homing" +msgstr "Referenciado de Z" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "" +"How the Z axis finds its reference: a home switch, or the auto-focus probe" +msgstr "" +"Cómo encuentra el eje Z su referencia: un interruptor de origen o la sonda " +"de autoenfoque" + #: rayforge/ui_gtk/machine/advanced_preferences_page.py msgid "Clear Alarm On Connect" msgstr "Desactivar alarma al conectar" @@ -5891,6 +6060,47 @@ msgstr "" "Estas notas se guardan con esta máquina y nunca se sobrescriben con las " "actualizaciones del perfil del dispositivo." +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "Offset" +msgstr "Desplazamiento" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "Positive values shift right-to-left passes to the right" +msgstr "" +"Los valores positivos desplazan las pasadas de derecha a izquierda hacia la " +"derecha" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "Remove Row" +msgstr "Eliminar fila" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +#, python-brace-format +msgid "{speed}: {offset}" +msgstr "{speed}: {offset}" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "Bidirectional Scan Offset by Speed" +msgstr "Desplazamiento de escaneo bidireccional por velocidad" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "" +"Shift of right-to-left raster passes at each engrave speed, interpolated " +"between rows. Engrave steps use it when their own Bidirectional Scan Offset " +"is 0." +msgstr "" +"Desplazamiento de las pasadas ráster de derecha a izquierda en cada " +"velocidad de grabado, interpolado entre filas. Los pasos de grabado lo usan " +"cuando su propio desplazamiento de escaneo bidireccional es 0." + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "Add Row" +msgstr "Añadir fila" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "No rows: all speeds use an offset of 0" +msgstr "Sin filas: todas las velocidades usan un desplazamiento de 0" + #: rayforge/ui_gtk/machine/wizard_pages/camera_page.py msgid "" "Optional. Configure any cameras you want to use for preview and alignment." @@ -7723,6 +7933,10 @@ msgstr "Abrir recientes" msgid "Import..." msgstr "Importar..." +#: rayforge/ui_gtk/main_menu.py +msgid "Relink Source File..." +msgstr "Volver a vincular archivo de origen..." + #: rayforge/ui_gtk/main_menu.py msgid "Export G-code..." msgstr "Exportar G-code..." @@ -7918,7 +8132,7 @@ msgstr "Organizar" msgid "_Tools" msgstr "_Herramientas" -#: rayforge/ui_gtk/main_menu.py +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/doceditor/frame_popover.py msgid "Frame" msgstr "Encuadrar" @@ -8161,79 +8375,81 @@ msgstr "Ocurrió un error inesperado: {error}" #: rayforge/ui_gtk/gestures/model.py msgid "Click" -msgstr "" +msgstr "Clic" #: rayforge/ui_gtk/gestures/model.py msgid "Double-click" -msgstr "" +msgstr "Doble clic" #: rayforge/ui_gtk/gestures/model.py msgid "Drag" -msgstr "" +msgstr "Arrastrar" #: rayforge/ui_gtk/gestures/model.py msgid "Scroll" -msgstr "" +msgstr "Desplazar" #: rayforge/ui_gtk/gestures/model.py msgid "Middle" -msgstr "" +msgstr "Central" #: rayforge/ui_gtk/gestures/model.py msgid "Shift" -msgstr "" +msgstr "Mayús" #: rayforge/ui_gtk/gestures/model.py msgid "Ctrl" -msgstr "" +msgstr "Ctrl" #: rayforge/ui_gtk/gestures/model.py msgid "Alt" -msgstr "" +msgstr "Alt" #: rayforge/ui_gtk/gestures/builtin.py msgid "2D Canvas" -msgstr "" +msgstr "Lienzo 2D" #: rayforge/ui_gtk/gestures/builtin.py msgid "Navigation gestures on the 2D work surface." -msgstr "" +msgstr "Gestos de navegación en la superficie de trabajo 2D." #: rayforge/ui_gtk/gestures/builtin.py msgid "3D Canvas" -msgstr "" +msgstr "Lienzo 3D" #: rayforge/ui_gtk/gestures/builtin.py msgid "Navigation gestures on the 3D preview." -msgstr "" +msgstr "Gestos de navegación en la vista previa 3D." #: rayforge/ui_gtk/gestures/builtin.py msgid "Pan the view" -msgstr "" +msgstr "Desplazar la vista" #: rayforge/ui_gtk/gestures/builtin.py msgid "Hold the mouse button down and move to pan the view." -msgstr "" +msgstr "Mantén pulsado el botón del ratón y muévelo para desplazar la vista." #: rayforge/ui_gtk/gestures/builtin.py msgid "Orbit the camera" -msgstr "" +msgstr "Orbitar la cámara" #: rayforge/ui_gtk/gestures/builtin.py msgid "Hold the mouse button down and move to orbit around the scene." msgstr "" +"Mantén pulsado el botón del ratón y muévelo para orbitar alrededor de la " +"escena." #: rayforge/ui_gtk/gestures/builtin.py msgid "Pan the camera" -msgstr "" +msgstr "Desplazar la cámara" #: rayforge/ui_gtk/gestures/builtin.py msgid "Hold the mouse button down and move to pan the scene." -msgstr "" +msgstr "Mantén pulsado el botón del ratón y muévelo para desplazar la escena." #: rayforge/ui_gtk/gestures/builtin.py msgid "Rotate around the Z axis" -msgstr "" +msgstr "Girar alrededor del eje Z" #: rayforge/ui_gtk/project_cmd.py msgid "Unsaved Changes" @@ -8707,10 +8923,25 @@ msgid "Failed to create library. A folder with that name may already exist." msgstr "" "Error al crear la biblioteca. Puede que ya exista una carpeta con ese nombre." +#: rayforge/ui_gtk/doceditor/frame_popover.py +msgid "Include Overscan Margin" +msgstr "Incluir el margen de overscan" + +#: rayforge/ui_gtk/doceditor/frame_popover.py +msgid "Frame the full machine path, including the extra engraving travel" +msgstr "" +"Enmarca la ruta completa de la máquina, incluido el recorrido adicional de " +"grabado" + #: rayforge/ui_gtk/doceditor/file_dialogs.py msgid "Open File" msgstr "Abrir archivo" +#: rayforge/ui_gtk/doceditor/file_dialogs.py +#, python-brace-format +msgid "Relink “{name}”" +msgstr "Volver a vincular «{name}»" + #: rayforge/ui_gtk/doceditor/file_dialogs.py msgid "All supported" msgstr "Todos los compatibles" @@ -8831,6 +9062,10 @@ msgstr "Personalizado" msgid "Add New Layer" msgstr "Añadir nueva capa" +#: rayforge/ui_gtk/doceditor/path_tolerance_dialog.py +msgid "Tolerance" +msgstr "Tolerancia" + #: rayforge/ui_gtk/doceditor/stock_properties_dialog.py #: rayforge/ui_gtk/doceditor/item_properties.py msgid "Stock Properties" @@ -8971,6 +9206,43 @@ msgstr "La máquina debe estar conectada para establecer el cero aquí" msgid "Set current position as 0" msgstr "Establecer posición actual como 0" +#: rayforge/ui_gtk/doceditor/source_changed_dialog.py +msgid "Imported File Changed" +msgid_plural "Imported Files Changed" +msgstr[0] "Archivo importado modificado" +msgstr[1] "Archivos importados modificados" + +#: rayforge/ui_gtk/doceditor/source_changed_dialog.py +#, python-brace-format +msgid "" +"\"{name}\" was changed on disk. Reload it? Position, size and settings are " +"kept." +msgstr "" +"«{name}» se modificó en el disco. ¿Recargarlo? Se mantienen la posición, el " +"tamaño y la configuración." + +#: rayforge/ui_gtk/doceditor/source_changed_dialog.py +msgid "" +"These imported files were changed on disk. Reload them? Position, size and " +"settings are kept." +msgstr "" +"Estos archivos importados se modificaron en el disco. ¿Recargarlos? Se " +"mantienen la posición, el tamaño y la configuración." + +#: rayforge/ui_gtk/doceditor/source_changed_dialog.py +msgid "_Ignore" +msgstr "_Ignorar" + +#: rayforge/ui_gtk/doceditor/source_changed_dialog.py +msgid "_Reload" +msgstr "_Recargar" + +#: rayforge/ui_gtk/doceditor/source_changed_dialog.py +msgid "Always reload this file without asking" +msgid_plural "Always reload the selected files without asking" +msgstr[0] "Recargar siempre este archivo sin preguntar" +msgstr[1] "Recargar siempre los archivos seleccionados sin preguntar" + #: rayforge/ui_gtk/doceditor/step_type_selection_dialog.py msgid "Select Step Types" msgstr "Seleccionar tipos de paso" @@ -10204,6 +10476,77 @@ msgstr "Reordenar piezas de trabajo" msgid "Reorder items" msgstr "Reordenar elementos" +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "{name} ({x:.1f}, {y:.1f} mm)" +msgstr "{name} ({x:.1f}, {y:.1f} mm)" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "Could not reload {name}: {error}" +msgstr "No se pudo recargar {name}: {error}" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "Reloaded {name}" +msgstr "{name} recargado" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "{n} new element added" +msgid_plural "{n} new elements added" +msgstr[0] "{n} elemento nuevo añadido" +msgstr[1] "{n} elementos nuevos añadidos" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "{n} element no longer in the file was removed" +msgid_plural "{n} elements no longer in the file were removed" +msgstr[0] "{n} elemento que ya no está en el archivo se eliminó" +msgstr[1] "{n} elementos que ya no están en el archivo se eliminaron" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "{n} edited element was left unchanged" +msgid_plural "{n} edited elements were left unchanged" +msgstr[0] "{n} elemento editado se dejó sin cambios" +msgstr[1] "{n} elementos editados se dejaron sin cambios" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "and {n} more" +msgstr "y {n} más" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "" +"{n} tab more than {limit:.1f} mm from the new outline was removed: {places}" +msgid_plural "" +"{n} tabs more than {limit:.1f} mm from the new outline were removed: {places}" +msgstr[0] "" +"{n} pestaña de sujeción a más de {limit:.1f} mm del nuevo contorno se " +"eliminó: {places}" +msgstr[1] "" +"{n} pestañas de sujeción a más de {limit:.1f} mm del nuevo contorno se " +"eliminaron: {places}" + +#: rayforge/doceditor/reload_cmd.py +msgid "the file type is not known" +msgstr "el tipo de archivo no se reconoce" + +#: rayforge/doceditor/reload_cmd.py +msgid "the file could not be read" +msgstr "no se pudo leer el archivo" + +#: rayforge/doceditor/reload_cmd.py +msgid "the file contains nothing to import" +msgstr "el archivo no contiene nada que importar" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "Reload {name}" +msgstr "Recargar {name}" + #: rayforge/doceditor/array_cmd.py msgid "Create Array" msgstr "Crear matriz" @@ -10212,6 +10555,22 @@ msgstr "Crear matriz" msgid "Create array copy" msgstr "Crear copia de matriz" +#: rayforge/doceditor/path_cleanup_cmd.py +msgid "Close paths" +msgstr "Cerrar trazados" + +#: rayforge/doceditor/path_cleanup_cmd.py +msgid "Join paths" +msgstr "Unir trazados" + +#: rayforge/doceditor/path_cleanup_cmd.py +msgid "Delete duplicates" +msgstr "Eliminar duplicados" + +#: rayforge/doceditor/path_cleanup_cmd.py +msgid "Break apart" +msgstr "Separar" + #: rayforge/doceditor/editor.py #, python-brace-format msgid "{count} asset(s) require disabled addon '{addon}'" @@ -10289,11 +10648,27 @@ msgstr "Posicionar en punto" msgid "Auto Layout" msgstr "Disposición automática" +#: rayforge/image/hpgl/importer.py +#, python-brace-format +msgid "Some HPGL instructions are not supported and were skipped: {names}" +msgstr "Algunas instrucciones HPGL no son compatibles y se omitieron: {names}" + +#: rayforge/image/hpgl/importer.py +#, python-brace-format +msgid "{filename} contains no HPGL drawing commands." +msgstr "{filename} no contiene comandos de dibujo HPGL." + +#: rayforge/image/hpgl/importer.py +#, python-brace-format +msgid "Pen {number}" +msgstr "Pluma {number}" + #: rayforge/image/png/importer.py msgid "Failed to scan PNG file: {}" msgstr "Error al escanear el archivo PNG: {}" -#: rayforge/image/png/importer.py rayforge/image/jpg/importer.py +#: rayforge/image/png/importer.py rayforge/image/bitmap/importer.py +#: rayforge/image/jpg/importer.py msgid "Failed to process image data." msgstr "Error al procesar datos de imagen." @@ -10329,6 +10704,15 @@ msgstr "Error al rasterizar SVG para trazar." msgid "Failed to normalize image data." msgstr "Error al normalizar datos de imagen." +#: rayforge/image/svg/importer.py +msgid "This SVGZ file's gzip data could not be decompressed." +msgstr "" + +#: rayforge/image/bitmap/importer.py +#, python-brace-format +msgid "Could not read the {format} image: {error}" +msgstr "No se pudo leer la imagen {format}: {error}" + #: rayforge/image/pdf/pdf_trace.py rayforge/image/pdf/pdf_vector.py msgid "PDF file contains no pages." msgstr "El archivo PDF no contiene páginas." @@ -10434,57 +10818,60 @@ msgid "Floyd Steinberg" msgstr "Floyd Steinberg" #: rayforge/image/dither.py -msgid "Bayer 2" -msgstr "Bayer 2" +msgid "Atkinson" +msgstr "Atkinson" #: rayforge/image/dither.py -msgid "Bayer 4" -msgstr "Bayer 4" +msgid "Stucki" +msgstr "Stucki" #: rayforge/image/dither.py -msgid "Bayer 8" -msgstr "Bayer 8" +msgid "Jarvis, Judice & Ninke" +msgstr "Jarvis, Judice & Ninke" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "View Outside Workspace" -msgstr "Vista fuera del área de trabajo" +#: rayforge/image/dither.py +msgid "Sierra" +msgstr "Sierra" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Show the camera image in a margin around the work area to help position workpieces. Requires image alignment." -msgstr "Muestra la imagen de la cámara en un margen alrededor del área de trabajo para ayudar a posicionar las piezas. Requiere la alineación de la imagen." +#: rayforge/image/dither.py +msgid "Sierra 2-Row" +msgstr "Sierra 2-Row" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Show Outside Workspace" -msgstr "Mostrar fuera del área de trabajo" +#: rayforge/image/dither.py +msgid "Sierra Lite" +msgstr "Sierra Lite" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Draw the camera image around the work area" -msgstr "Dibujar la imagen de la cámara alrededor del área de trabajo" +#: rayforge/image/dither.py +msgid "Burkes" +msgstr "Burkes" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Not shown until the camera image is aligned" -msgstr "No se muestra hasta que la imagen de la cámara esté alineada" +#: rayforge/image/dither.py +msgid "Bayer 2" +msgstr "Bayer 2" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Margin" -msgstr "Margen" +#: rayforge/image/dither.py +msgid "Bayer 4" +msgstr "Bayer 4" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Size of the visible area on each side of the work area" -msgstr "Tamaño del área visible a cada lado del área de trabajo" +#: rayforge/image/dither.py +msgid "Bayer 8" +msgstr "Bayer 8" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Transparency outside the work area" -msgstr "Transparencia fuera del área de trabajo" +#: rayforge/image/dither.py +msgid "Newsprint" +msgstr "Papel prensa" -#: rayforge/image/bitmap/importer.py -#, python-brace-format -msgid "Could not read the {format} image: {error}" -msgstr "No se pudo leer la imagen {format}: {error}" +#: rayforge/image/dither.py +msgid "Halftone" +msgstr "Semitono" #: rayforge/image/postscript/importer.py -msgid "Importing EPS and PostScript files requires Ghostscript, which was not found. Install Ghostscript and try again." -msgstr "Para importar archivos EPS y PostScript se necesita Ghostscript, que no se encontró. Instala Ghostscript e inténtalo de nuevo." +msgid "" +"Importing EPS and PostScript files requires Ghostscript, which was not " +"found. Install Ghostscript and try again." +msgstr "" +"Para importar archivos EPS y PostScript se necesita Ghostscript, que no se " +"encontró. Instala Ghostscript e inténtalo de nuevo." #: rayforge/image/postscript/importer.py #, python-brace-format @@ -10492,47 +10879,12 @@ msgid "Ghostscript could not convert {filename}: {error}" msgstr "Ghostscript no pudo convertir {filename}: {error}" #: rayforge/image/postscript/importer.py -msgid "This Illustrator file was saved without PDF compatibility. Rayforge can only read it with Ghostscript, which was not found. Install Ghostscript, or save the file again in Illustrator with \"Create PDF Compatible File\" enabled." -msgstr "Este archivo de Illustrator se guardó sin compatibilidad con PDF. Rayforge solo puede leerlo con Ghostscript, que no se encontró. Instala Ghostscript o vuelve a guardar el archivo en Illustrator con la opción «Crear archivo compatible con PDF» activada." - -#: rayforge/image/hpgl/importer.py -#, python-brace-format -msgid "Some HPGL instructions are not supported and were skipped: {names}" -msgstr "Algunas instrucciones HPGL no son compatibles y se omitieron: {names}" - -#: rayforge/image/hpgl/importer.py -#, python-brace-format -msgid "{filename} contains no HPGL drawing commands." -msgstr "{filename} no contiene comandos de dibujo HPGL." - -#: rayforge/image/hpgl/importer.py -#, python-brace-format -msgid "Pen {number}" -msgstr "Pluma {number}" - -#: rayforge/machine/driver/driver.py -msgid "Home Switch" -msgstr "Interruptor de origen" - -#: rayforge/machine/driver/driver.py -msgid "Probe" -msgstr "Sonda" - -#: rayforge/ui_gtk/machine/advanced_preferences_page.py -msgid "Z Homing" -msgstr "Referenciado de Z" - -#: rayforge/ui_gtk/machine/advanced_preferences_page.py msgid "" -"How the Z axis finds its reference: a home switch, " -"or the auto-focus probe" -msgstr "Cómo encuentra el eje Z su referencia: un interruptor de origen o la sonda de autoenfoque" - -#: rayforge/ui_gtk/doceditor/frame_popover.py -msgid "Include Overscan Margin" -msgstr "Incluir el margen de overscan" - -#: rayforge/ui_gtk/doceditor/frame_popover.py -msgid "" -"Frame the full machine path, including the extra engraving travel" -msgstr "Enmarca la ruta completa de la máquina, incluido el recorrido adicional de grabado" +"This Illustrator file was saved without PDF compatibility. Rayforge can only " +"read it with Ghostscript, which was not found. Install Ghostscript, or save " +"the file again in Illustrator with \"Create PDF Compatible File\" enabled." +msgstr "" +"Este archivo de Illustrator se guardó sin compatibilidad con PDF. Rayforge " +"solo puede leerlo con Ghostscript, que no se encontró. Instala Ghostscript o " +"vuelve a guardar el archivo en Illustrator con la opción «Crear archivo " +"compatible con PDF» activada." diff --git a/rayforge/locale/fr/LC_MESSAGES/rayforge.po b/rayforge/locale/fr/LC_MESSAGES/rayforge.po index a9b9cf80b..f146ebea5 100644 --- a/rayforge/locale/fr/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/fr/LC_MESSAGES/rayforge.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: rayforge\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-10-06 22:41+0200\n" +"POT-Creation-Date: 2026-10-15 02:25+0200\n" "PO-Revision-Date: 2025-10-15 07:10+0200\n" "Last-Translator: Samuel \n" "Language-Team: French\n" @@ -444,6 +444,18 @@ msgstr "" "Quel transport utiliser. Automatique ouvre l’USB lorsqu’il est disponible et " "bascule sur le réseau sinon." +#: rayforge/machine/driver/ruidarpa/rpa_adapter.py +msgid "TCP protocol" +msgstr "Protocole TCP" + +#: rayforge/machine/driver/ruidarpa/rpa_adapter.py +msgid "" +"Use TCP instead of UDP for the network connection. Needed by controllers " +"that do not respond over UDP." +msgstr "" +"Utiliser TCP au lieu d'UDP pour la connexion réseau. Nécessaire aux " +"contrôleurs qui ne répondent pas en UDP." + #: rayforge/machine/driver/ruidarpa/rpa_adapter.py #: rayforge/machine/driver/octoprint/octoprint_driver.py #: rayforge/machine/driver/grbl/grbl_telnet.py @@ -630,6 +642,14 @@ msgstr "" "Ce pilote est expérimental et presque certainement buggé. Il peut ne pas " "fonctionner de manière fiable. Utilisez-le à vos propres risques." +#: rayforge/machine/driver/driver.py +msgid "Home Switch" +msgstr "Interrupteur d'origine" + +#: rayforge/machine/driver/driver.py +msgid "Probe" +msgstr "Palpeur" + #: rayforge/machine/driver/driver.py rayforge/machine/transport/transport.py #: rayforge/ui_gtk/machine/machine_dropdown.py #: rayforge/ui_gtk/machine/status_widget.py @@ -2685,6 +2705,7 @@ msgstr "" #: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py #: rayforge/ui_gtk/doceditor/material_library_list.py #: rayforge/ui_gtk/doceditor/add_material_dialog.py +#: rayforge/ui_gtk/doceditor/path_tolerance_dialog.py #: rayforge/ui_gtk/doceditor/step_type_selection_dialog.py #: rayforge/ui_gtk/doceditor/material_selector.py #: rayforge/ui_gtk/doceditor/material_list.py @@ -2918,16 +2939,16 @@ msgstr "Installer, mettre à jour et supprimer des extensions." #: rayforge/ui_gtk/settings/gesture_preferences_page.py msgid "Mouse Gestures" -msgstr "" +msgstr "Gestes de la souris" #: rayforge/ui_gtk/settings/gesture_preferences_page.py #, python-format msgid "Default: %s" -msgstr "" +msgstr "Par défaut : %s" #: rayforge/ui_gtk/settings/gesture_preferences_page.py msgid "Unassigned" -msgstr "" +msgstr "Non attribué" #: rayforge/ui_gtk/settings/recipe_manager_page.py msgid "Recipes" @@ -3211,6 +3232,7 @@ msgid "Length" msgstr "Longueur" #: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/machine/bidir_offset_table.py #: rayforge/ui_gtk/doceditor/bottom_panel.py msgid "Speed" msgstr "Vitesse" @@ -3250,6 +3272,15 @@ msgid "Automatically check for new Rayforge versions on startup" msgstr "" "Vérifier automatiquement les nouvelles versions de Rayforge au démarrage" +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Watch imported files" +msgstr "Surveiller les fichiers importés" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Offer to reload imported files when they change on disk" +msgstr "" +"Proposer de recharger les fichiers importés lorsqu'ils changent sur le disque" + #: rayforge/ui_gtk/settings/general_preferences_page.py msgid "Startup behavior" msgstr "Comportement au démarrage" @@ -3429,10 +3460,42 @@ msgstr "Grouper" msgid "Ungroup" msgstr "Dégrouper" +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +msgid "Break Apart" +msgstr "Dissocier" + +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +msgid "Close Paths…" +msgstr "Fermer les chemins…" + +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +msgid "Join Open Paths…" +msgstr "Joindre les chemins ouverts…" + +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +msgid "Delete Duplicates" +msgstr "Supprimer les doublons" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +msgid "Clean Up Paths" +msgstr "Nettoyer les chemins" + #: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/doceditor/stock_cmd.py msgid "Convert to Stock" msgstr "Convertir en matériau" +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +#: rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Reload from Disk" +msgstr "Recharger depuis le disque" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +#: rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Relink Source File…" +msgstr "Relier le fichier source…" + #: rayforge/ui_gtk/canvas2d/context_menu.py msgid "Add Tab Here" msgstr "Ajouter une languette ici" @@ -3499,6 +3562,65 @@ msgstr "Ajouter un brut" msgid "Auto Layout (Simple)" msgstr "Disposition automatique (Simple)" +#: rayforge/ui_gtk/actions.py +#, python-brace-format +msgid "The file of {name} cannot be found. Use Relink to pick it." +msgstr "" +"Le fichier de {name} est introuvable. Utilisez « Relier » pour le " +"sélectionner." + +#: rayforge/ui_gtk/actions.py +msgid "Nothing to break apart" +msgstr "Rien à dissocier" + +#: rayforge/ui_gtk/actions.py +#, python-brace-format +msgid "Closed {count} path" +msgid_plural "Closed {count} paths" +msgstr[0] "{count} chemin fermé" +msgstr[1] "{count} chemins fermés" + +#: rayforge/ui_gtk/actions.py +msgid "Close Paths" +msgstr "Fermer les chemins" + +#: rayforge/ui_gtk/actions.py +msgid "" +"Closes open paths whose start and end point are closer than the tolerance." +msgstr "" +"Ferme les chemins ouverts dont le point de départ et d'arrivée sont plus " +"proches que la tolérance." + +#: rayforge/ui_gtk/actions.py +#, python-brace-format +msgid "Made {count} join" +msgid_plural "Made {count} joins" +msgstr[0] "{count} jonction réalisée" +msgstr[1] "{count} jonctions réalisées" + +#: rayforge/ui_gtk/actions.py +msgid "Join Open Paths" +msgstr "Joindre les chemins ouverts" + +#: rayforge/ui_gtk/actions.py +msgid "" +"Joins open paths whose end points are closer than the tolerance. Paths whose " +"ends then meet are closed." +msgstr "" +"Joint les chemins ouverts dont les extrémités sont plus proches que la " +"tolérance. Les chemins dont les extrémités se rejoignent sont fermés." + +#: rayforge/ui_gtk/actions.py +msgid "Join Paths" +msgstr "Joindre les chemins" + +#: rayforge/ui_gtk/actions.py +#, python-brace-format +msgid "Deleted {count} duplicate" +msgid_plural "Deleted {count} duplicates" +msgstr[0] "{count} doublon supprimé" +msgstr[1] "{count} doublons supprimés" + #: rayforge/ui_gtk/camera/lens_calibration_dialog.py #, python-brace-format msgid "{camera_name} - Lens Calibration" @@ -3594,6 +3716,7 @@ msgid "Temporal averaging, higher values cause trailing" msgstr "Moyenne temporelle, les valeurs élevées provoquent des traînées" #: rayforge/ui_gtk/camera/image_settings_widget.py +#: rayforge/ui_gtk/camera/outside_view_widget.py msgid "Transparency" msgstr "Transparence" @@ -4335,6 +4458,42 @@ msgstr "Caméras" msgid "Stream a camera image directly onto the work surface." msgstr "Diffuser une image de la caméra directement sur la surface de travail." +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "View Outside Workspace" +msgstr "Vue hors de la zone de travail" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "" +"Show the camera image in a margin around the work area to help position " +"workpieces. Requires image alignment." +msgstr "" +"Affiche l’image de la caméra dans une marge autour de la zone de travail " +"pour aider à positionner les pièces. Nécessite l’alignement de l’image." + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Show Outside Workspace" +msgstr "Afficher hors de la zone de travail" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Draw the camera image around the work area" +msgstr "Dessiner l’image de la caméra autour de la zone de travail" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Margin" +msgstr "Marge" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Size of the visible area on each side of the work area" +msgstr "Taille de la zone visible de chaque côté de la zone de travail" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Transparency outside the work area" +msgstr "Transparence hors de la zone de travail" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Not shown until the camera image is aligned" +msgstr "Non affichée tant que l’image de la caméra n’est pas alignée" + #: rayforge/ui_gtk/camera/alignment_widget.py msgid "" "Click the image to add reference points. Drag to move them.\n" @@ -5450,6 +5609,17 @@ msgstr "" "Activer les commandes de prise d'origine par axe dans la boîte de dialogue " "de déplacement manuel." +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Z Homing" +msgstr "Prise d'origine Z" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "" +"How the Z axis finds its reference: a home switch, or the auto-focus probe" +msgstr "" +"Comment l'axe Z trouve sa référence : un interrupteur d'origine ou le " +"palpeur d'autofocus" + #: rayforge/ui_gtk/machine/advanced_preferences_page.py msgid "Clear Alarm On Connect" msgstr "Effacer l'alarme à la connexion" @@ -5906,6 +6076,46 @@ msgstr "" "Ces notes sont enregistrées avec cette machine et ne sont jamais écrasées " "par les mises à jour du profil de l’appareil." +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "Offset" +msgstr "Décalage" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "Positive values shift right-to-left passes to the right" +msgstr "" +"Les valeurs positives décalent les passes de droite à gauche vers la droite" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "Remove Row" +msgstr "Supprimer la ligne" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +#, python-brace-format +msgid "{speed}: {offset}" +msgstr "{speed} : {offset}" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "Bidirectional Scan Offset by Speed" +msgstr "Décalage de balayage bidirectionnel selon la vitesse" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "" +"Shift of right-to-left raster passes at each engrave speed, interpolated " +"between rows. Engrave steps use it when their own Bidirectional Scan Offset " +"is 0." +msgstr "" +"Décalage des passes de balayage de droite à gauche à chaque vitesse de " +"gravure, interpolé entre les lignes. Les étapes de gravure l'utilisent " +"lorsque leur propre décalage de balayage bidirectionnel est 0." + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "Add Row" +msgstr "Ajouter une ligne" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "No rows: all speeds use an offset of 0" +msgstr "Aucune ligne : toutes les vitesses utilisent un décalage de 0" + #: rayforge/ui_gtk/machine/wizard_pages/camera_page.py msgid "" "Optional. Configure any cameras you want to use for preview and alignment." @@ -7757,6 +7967,10 @@ msgstr "Ouvrir les récents" msgid "Import..." msgstr "Importer..." +#: rayforge/ui_gtk/main_menu.py +msgid "Relink Source File..." +msgstr "Relier le fichier source..." + #: rayforge/ui_gtk/main_menu.py msgid "Export G-code..." msgstr "Exporter le G-code..." @@ -7952,7 +8166,7 @@ msgstr "Agencer" msgid "_Tools" msgstr "_Outils" -#: rayforge/ui_gtk/main_menu.py +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/doceditor/frame_popover.py msgid "Frame" msgstr "Cadrer" @@ -8195,79 +8409,84 @@ msgstr "Une erreur inattendue s’est produite : {error}" #: rayforge/ui_gtk/gestures/model.py msgid "Click" -msgstr "" +msgstr "Clic" #: rayforge/ui_gtk/gestures/model.py msgid "Double-click" -msgstr "" +msgstr "Double-clic" #: rayforge/ui_gtk/gestures/model.py msgid "Drag" -msgstr "" +msgstr "Glisser" #: rayforge/ui_gtk/gestures/model.py msgid "Scroll" -msgstr "" +msgstr "Défiler" #: rayforge/ui_gtk/gestures/model.py msgid "Middle" -msgstr "" +msgstr "Milieu" #: rayforge/ui_gtk/gestures/model.py msgid "Shift" -msgstr "" +msgstr "Maj" #: rayforge/ui_gtk/gestures/model.py msgid "Ctrl" -msgstr "" +msgstr "Ctrl" #: rayforge/ui_gtk/gestures/model.py msgid "Alt" -msgstr "" +msgstr "Alt" #: rayforge/ui_gtk/gestures/builtin.py msgid "2D Canvas" -msgstr "" +msgstr "Canevas 2D" #: rayforge/ui_gtk/gestures/builtin.py msgid "Navigation gestures on the 2D work surface." -msgstr "" +msgstr "Gestes de navigation sur la surface de travail 2D." #: rayforge/ui_gtk/gestures/builtin.py msgid "3D Canvas" -msgstr "" +msgstr "Canevas 3D" #: rayforge/ui_gtk/gestures/builtin.py msgid "Navigation gestures on the 3D preview." -msgstr "" +msgstr "Gestes de navigation dans l'aperçu 3D." #: rayforge/ui_gtk/gestures/builtin.py msgid "Pan the view" -msgstr "" +msgstr "Déplacer la vue" #: rayforge/ui_gtk/gestures/builtin.py msgid "Hold the mouse button down and move to pan the view." msgstr "" +"Maintenez le bouton de la souris enfoncé et déplacez-la pour déplacer la vue." #: rayforge/ui_gtk/gestures/builtin.py msgid "Orbit the camera" -msgstr "" +msgstr "Orbiter autour de la caméra" #: rayforge/ui_gtk/gestures/builtin.py msgid "Hold the mouse button down and move to orbit around the scene." msgstr "" +"Maintenez le bouton de la souris enfoncé et déplacez-la pour orbiter autour " +"de la scène." #: rayforge/ui_gtk/gestures/builtin.py msgid "Pan the camera" -msgstr "" +msgstr "Déplacer la caméra" #: rayforge/ui_gtk/gestures/builtin.py msgid "Hold the mouse button down and move to pan the scene." msgstr "" +"Maintenez le bouton de la souris enfoncé et déplacez-la pour déplacer la " +"scène." #: rayforge/ui_gtk/gestures/builtin.py msgid "Rotate around the Z axis" -msgstr "" +msgstr "Tourner autour de l'axe Z" #: rayforge/ui_gtk/project_cmd.py msgid "Unsaved Changes" @@ -8745,10 +8964,25 @@ msgstr "" "Échec de la création de la bibliothèque. Un dossier du même nom existe peut-" "être déjà." +#: rayforge/ui_gtk/doceditor/frame_popover.py +msgid "Include Overscan Margin" +msgstr "Inclure la marge d'overscan" + +#: rayforge/ui_gtk/doceditor/frame_popover.py +msgid "Frame the full machine path, including the extra engraving travel" +msgstr "" +"Encadre le trajet complet de la machine, y compris le parcours de gravure " +"supplémentaire" + #: rayforge/ui_gtk/doceditor/file_dialogs.py msgid "Open File" msgstr "Ouvrir un fichier" +#: rayforge/ui_gtk/doceditor/file_dialogs.py +#, python-brace-format +msgid "Relink “{name}”" +msgstr "Relier « {name} »" + #: rayforge/ui_gtk/doceditor/file_dialogs.py msgid "All supported" msgstr "Tous les formats supportés" @@ -8869,6 +9103,10 @@ msgstr "Personnalisé" msgid "Add New Layer" msgstr "Ajouter un nouveau calque" +#: rayforge/ui_gtk/doceditor/path_tolerance_dialog.py +msgid "Tolerance" +msgstr "Tolérance" + #: rayforge/ui_gtk/doceditor/stock_properties_dialog.py #: rayforge/ui_gtk/doceditor/item_properties.py msgid "Stock Properties" @@ -9009,6 +9247,43 @@ msgstr "La machine doit être connectée pour définir le zéro ici" msgid "Set current position as 0" msgstr "Définir la position actuelle comme 0" +#: rayforge/ui_gtk/doceditor/source_changed_dialog.py +msgid "Imported File Changed" +msgid_plural "Imported Files Changed" +msgstr[0] "Fichier importé modifié" +msgstr[1] "Fichiers importés modifiés" + +#: rayforge/ui_gtk/doceditor/source_changed_dialog.py +#, python-brace-format +msgid "" +"\"{name}\" was changed on disk. Reload it? Position, size and settings are " +"kept." +msgstr "" +"« {name} » a été modifié sur le disque. Le recharger ? La position, la " +"taille et les réglages sont conservés." + +#: rayforge/ui_gtk/doceditor/source_changed_dialog.py +msgid "" +"These imported files were changed on disk. Reload them? Position, size and " +"settings are kept." +msgstr "" +"Ces fichiers importés ont été modifiés sur le disque. Les recharger ? La " +"position, la taille et les réglages sont conservés." + +#: rayforge/ui_gtk/doceditor/source_changed_dialog.py +msgid "_Ignore" +msgstr "_Ignorer" + +#: rayforge/ui_gtk/doceditor/source_changed_dialog.py +msgid "_Reload" +msgstr "_Recharger" + +#: rayforge/ui_gtk/doceditor/source_changed_dialog.py +msgid "Always reload this file without asking" +msgid_plural "Always reload the selected files without asking" +msgstr[0] "Toujours recharger ce fichier sans demander" +msgstr[1] "Toujours recharger les fichiers sélectionnés sans demander" + #: rayforge/ui_gtk/doceditor/step_type_selection_dialog.py msgid "Select Step Types" msgstr "Sélectionner les types d'étapes" @@ -10251,6 +10526,77 @@ msgstr "Réorganiser les pièces" msgid "Reorder items" msgstr "Réorganiser les éléments" +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "{name} ({x:.1f}, {y:.1f} mm)" +msgstr "{name} ({x:.1f}, {y:.1f} mm)" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "Could not reload {name}: {error}" +msgstr "Impossible de recharger {name} : {error}" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "Reloaded {name}" +msgstr "{name} rechargé" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "{n} new element added" +msgid_plural "{n} new elements added" +msgstr[0] "{n} nouvel élément ajouté" +msgstr[1] "{n} nouveaux éléments ajoutés" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "{n} element no longer in the file was removed" +msgid_plural "{n} elements no longer in the file were removed" +msgstr[0] "{n} élément qui n'est plus dans le fichier a été supprimé" +msgstr[1] "{n} éléments qui ne sont plus dans le fichier ont été supprimés" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "{n} edited element was left unchanged" +msgid_plural "{n} edited elements were left unchanged" +msgstr[0] "{n} élément modifié a été laissé inchangé" +msgstr[1] "{n} éléments modifiés ont été laissés inchangés" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "and {n} more" +msgstr "et {n} de plus" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "" +"{n} tab more than {limit:.1f} mm from the new outline was removed: {places}" +msgid_plural "" +"{n} tabs more than {limit:.1f} mm from the new outline were removed: {places}" +msgstr[0] "" +"{n} languette située à plus de {limit:.1f} mm du nouveau contour a été " +"supprimée : {places}" +msgstr[1] "" +"{n} languettes situées à plus de {limit:.1f} mm du nouveau contour ont été " +"supprimées : {places}" + +#: rayforge/doceditor/reload_cmd.py +msgid "the file type is not known" +msgstr "le type de fichier est inconnu" + +#: rayforge/doceditor/reload_cmd.py +msgid "the file could not be read" +msgstr "le fichier n'a pas pu être lu" + +#: rayforge/doceditor/reload_cmd.py +msgid "the file contains nothing to import" +msgstr "le fichier ne contient rien à importer" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "Reload {name}" +msgstr "Recharger {name}" + #: rayforge/doceditor/array_cmd.py msgid "Create Array" msgstr "Créer un tableau" @@ -10259,6 +10605,22 @@ msgstr "Créer un tableau" msgid "Create array copy" msgstr "Créer une copie du tableau" +#: rayforge/doceditor/path_cleanup_cmd.py +msgid "Close paths" +msgstr "Fermer les chemins" + +#: rayforge/doceditor/path_cleanup_cmd.py +msgid "Join paths" +msgstr "Joindre les chemins" + +#: rayforge/doceditor/path_cleanup_cmd.py +msgid "Delete duplicates" +msgstr "Supprimer les doublons" + +#: rayforge/doceditor/path_cleanup_cmd.py +msgid "Break apart" +msgstr "Dissocier" + #: rayforge/doceditor/editor.py #, python-brace-format msgid "{count} asset(s) require disabled addon '{addon}'" @@ -10336,11 +10698,29 @@ msgstr "Positionner au point" msgid "Auto Layout" msgstr "Disposition automatique" +#: rayforge/image/hpgl/importer.py +#, python-brace-format +msgid "Some HPGL instructions are not supported and were skipped: {names}" +msgstr "" +"Certaines instructions HPGL ne sont pas prises en charge et ont été " +"ignorées : {names}" + +#: rayforge/image/hpgl/importer.py +#, python-brace-format +msgid "{filename} contains no HPGL drawing commands." +msgstr "{filename} ne contient aucune commande de tracé HPGL." + +#: rayforge/image/hpgl/importer.py +#, python-brace-format +msgid "Pen {number}" +msgstr "Plume {number}" + #: rayforge/image/png/importer.py msgid "Failed to scan PNG file: {}" msgstr "Échec de l'analyse du fichier PNG : {}" -#: rayforge/image/png/importer.py rayforge/image/jpg/importer.py +#: rayforge/image/png/importer.py rayforge/image/bitmap/importer.py +#: rayforge/image/jpg/importer.py msgid "Failed to process image data." msgstr "Échec du traitement des données d'image." @@ -10376,6 +10756,15 @@ msgstr "Échec de la rasterisation du SVG pour le traçage." msgid "Failed to normalize image data." msgstr "Échec de la normalisation des données d'image." +#: rayforge/image/svg/importer.py +msgid "This SVGZ file's gzip data could not be decompressed." +msgstr "" + +#: rayforge/image/bitmap/importer.py +#, python-brace-format +msgid "Could not read the {format} image: {error}" +msgstr "Impossible de lire l'image {format} : {error}" + #: rayforge/image/pdf/pdf_trace.py rayforge/image/pdf/pdf_vector.py msgid "PDF file contains no pages." msgstr "Le fichier PDF ne contient aucune page." @@ -10481,57 +10870,60 @@ msgid "Floyd Steinberg" msgstr "Floyd Steinberg" #: rayforge/image/dither.py -msgid "Bayer 2" -msgstr "Bayer 2" +msgid "Atkinson" +msgstr "Atkinson" #: rayforge/image/dither.py -msgid "Bayer 4" -msgstr "Bayer 4" +msgid "Stucki" +msgstr "Stucki" #: rayforge/image/dither.py -msgid "Bayer 8" -msgstr "Bayer 8" +msgid "Jarvis, Judice & Ninke" +msgstr "Jarvis, Judice & Ninke" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "View Outside Workspace" -msgstr "Vue hors de la zone de travail" +#: rayforge/image/dither.py +msgid "Sierra" +msgstr "Sierra" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Show the camera image in a margin around the work area to help position workpieces. Requires image alignment." -msgstr "Affiche l’image de la caméra dans une marge autour de la zone de travail pour aider à positionner les pièces. Nécessite l’alignement de l’image." +#: rayforge/image/dither.py +msgid "Sierra 2-Row" +msgstr "Sierra 2-Row" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Show Outside Workspace" -msgstr "Afficher hors de la zone de travail" +#: rayforge/image/dither.py +msgid "Sierra Lite" +msgstr "Sierra Lite" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Draw the camera image around the work area" -msgstr "Dessiner l’image de la caméra autour de la zone de travail" +#: rayforge/image/dither.py +msgid "Burkes" +msgstr "Burkes" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Not shown until the camera image is aligned" -msgstr "Non affichée tant que l’image de la caméra n’est pas alignée" +#: rayforge/image/dither.py +msgid "Bayer 2" +msgstr "Bayer 2" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Margin" -msgstr "Marge" +#: rayforge/image/dither.py +msgid "Bayer 4" +msgstr "Bayer 4" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Size of the visible area on each side of the work area" -msgstr "Taille de la zone visible de chaque côté de la zone de travail" +#: rayforge/image/dither.py +msgid "Bayer 8" +msgstr "Bayer 8" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Transparency outside the work area" -msgstr "Transparence hors de la zone de travail" +#: rayforge/image/dither.py +msgid "Newsprint" +msgstr "Papier journal" -#: rayforge/image/bitmap/importer.py -#, python-brace-format -msgid "Could not read the {format} image: {error}" -msgstr "Impossible de lire l'image {format} : {error}" +#: rayforge/image/dither.py +msgid "Halftone" +msgstr "Demi-teinte" #: rayforge/image/postscript/importer.py -msgid "Importing EPS and PostScript files requires Ghostscript, which was not found. Install Ghostscript and try again." -msgstr "L'importation de fichiers EPS et PostScript nécessite Ghostscript, qui est introuvable. Installez Ghostscript et réessayez." +msgid "" +"Importing EPS and PostScript files requires Ghostscript, which was not " +"found. Install Ghostscript and try again." +msgstr "" +"L'importation de fichiers EPS et PostScript nécessite Ghostscript, qui est " +"introuvable. Installez Ghostscript et réessayez." #: rayforge/image/postscript/importer.py #, python-brace-format @@ -10539,47 +10931,12 @@ msgid "Ghostscript could not convert {filename}: {error}" msgstr "Ghostscript n'a pas pu convertir {filename} : {error}" #: rayforge/image/postscript/importer.py -msgid "This Illustrator file was saved without PDF compatibility. Rayforge can only read it with Ghostscript, which was not found. Install Ghostscript, or save the file again in Illustrator with \"Create PDF Compatible File\" enabled." -msgstr "Ce fichier Illustrator a été enregistré sans compatibilité PDF. Rayforge ne peut le lire qu'avec Ghostscript, qui est introuvable. Installez Ghostscript ou enregistrez à nouveau le fichier dans Illustrator avec l'option « Créer un fichier compatible PDF » activée." - -#: rayforge/image/hpgl/importer.py -#, python-brace-format -msgid "Some HPGL instructions are not supported and were skipped: {names}" -msgstr "Certaines instructions HPGL ne sont pas prises en charge et ont été ignorées : {names}" - -#: rayforge/image/hpgl/importer.py -#, python-brace-format -msgid "{filename} contains no HPGL drawing commands." -msgstr "{filename} ne contient aucune commande de tracé HPGL." - -#: rayforge/image/hpgl/importer.py -#, python-brace-format -msgid "Pen {number}" -msgstr "Plume {number}" - -#: rayforge/machine/driver/driver.py -msgid "Home Switch" -msgstr "Interrupteur d'origine" - -#: rayforge/machine/driver/driver.py -msgid "Probe" -msgstr "Palpeur" - -#: rayforge/ui_gtk/machine/advanced_preferences_page.py -msgid "Z Homing" -msgstr "Prise d'origine Z" - -#: rayforge/ui_gtk/machine/advanced_preferences_page.py msgid "" -"How the Z axis finds its reference: a home switch, " -"or the auto-focus probe" -msgstr "Comment l'axe Z trouve sa référence : un interrupteur d'origine ou le palpeur d'autofocus" - -#: rayforge/ui_gtk/doceditor/frame_popover.py -msgid "Include Overscan Margin" -msgstr "Inclure la marge d'overscan" - -#: rayforge/ui_gtk/doceditor/frame_popover.py -msgid "" -"Frame the full machine path, including the extra engraving travel" -msgstr "Encadre le trajet complet de la machine, y compris le parcours de gravure supplémentaire" +"This Illustrator file was saved without PDF compatibility. Rayforge can only " +"read it with Ghostscript, which was not found. Install Ghostscript, or save " +"the file again in Illustrator with \"Create PDF Compatible File\" enabled." +msgstr "" +"Ce fichier Illustrator a été enregistré sans compatibilité PDF. Rayforge ne " +"peut le lire qu'avec Ghostscript, qui est introuvable. Installez Ghostscript " +"ou enregistrez à nouveau le fichier dans Illustrator avec l'option « Créer " +"un fichier compatible PDF » activée." diff --git a/rayforge/locale/hi/LC_MESSAGES/rayforge.po b/rayforge/locale/hi/LC_MESSAGES/rayforge.po index 2826cc548..96a2404e6 100644 --- a/rayforge/locale/hi/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/hi/LC_MESSAGES/rayforge.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: Rayforge\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-10-06 22:41+0200\n" +"POT-Creation-Date: 2026-10-15 02:25+0200\n" "PO-Revision-Date: 2026-09-27 22:58\n" "Last-Translator: Samuel Abels \n" "Language-Team: none\n" @@ -437,6 +437,18 @@ msgstr "" "कौन सा ट्रांसपोर्ट उपयोग करें. स्वतः उपलब्ध होने पर USB खोलता है और अन्यथा नेटवर्क का " "उपयोग करता है." +#: rayforge/machine/driver/ruidarpa/rpa_adapter.py +msgid "TCP protocol" +msgstr "TCP प्रोटोकॉल" + +#: rayforge/machine/driver/ruidarpa/rpa_adapter.py +msgid "" +"Use TCP instead of UDP for the network connection. Needed by controllers " +"that do not respond over UDP." +msgstr "" +"नेटवर्क कनेक्शन के लिए UDP के बजाय TCP का उपयोग करें। ऐसे कंट्रोलर के लिए आवश्यक जो UDP " +"पर जवाब नहीं देते।" + #: rayforge/machine/driver/ruidarpa/rpa_adapter.py #: rayforge/machine/driver/octoprint/octoprint_driver.py #: rayforge/machine/driver/grbl/grbl_telnet.py @@ -613,6 +625,14 @@ msgstr "" "यह ड्राइवर प्रयोगात्मक है और लगभग निश्चित रूप से बगयुक्त है. यह विश्वसनीय रूप से काम नहीं " "कर सकता. इसे अपने जोखिम पर उपयोग करें." +#: rayforge/machine/driver/driver.py +msgid "Home Switch" +msgstr "होम स्विच" + +#: rayforge/machine/driver/driver.py +msgid "Probe" +msgstr "प्रोब" + #: rayforge/machine/driver/driver.py rayforge/machine/transport/transport.py #: rayforge/ui_gtk/machine/machine_dropdown.py #: rayforge/ui_gtk/machine/status_widget.py @@ -2576,6 +2596,7 @@ msgstr "" #: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py #: rayforge/ui_gtk/doceditor/material_library_list.py #: rayforge/ui_gtk/doceditor/add_material_dialog.py +#: rayforge/ui_gtk/doceditor/path_tolerance_dialog.py #: rayforge/ui_gtk/doceditor/step_type_selection_dialog.py #: rayforge/ui_gtk/doceditor/material_selector.py #: rayforge/ui_gtk/doceditor/material_list.py @@ -2803,16 +2824,16 @@ msgstr "ऐड-ऑन इंस्टॉल, अपडेट और हटाए #: rayforge/ui_gtk/settings/gesture_preferences_page.py msgid "Mouse Gestures" -msgstr "" +msgstr "माउस इशारे" #: rayforge/ui_gtk/settings/gesture_preferences_page.py #, python-format msgid "Default: %s" -msgstr "" +msgstr "डिफ़ॉल्ट: %s" #: rayforge/ui_gtk/settings/gesture_preferences_page.py msgid "Unassigned" -msgstr "" +msgstr "अनिर्दिष्ट" #: rayforge/ui_gtk/settings/recipe_manager_page.py msgid "Recipes" @@ -3082,6 +3103,7 @@ msgid "Length" msgstr "लंबाई" #: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/machine/bidir_offset_table.py #: rayforge/ui_gtk/doceditor/bottom_panel.py msgid "Speed" msgstr "गति" @@ -3118,6 +3140,14 @@ msgstr "अपडेट के लिए जाँचें" msgid "Automatically check for new Rayforge versions on startup" msgstr "स्टार्टअप पर स्वतः नए Rayforge संस्करणों की जाँच करें" +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Watch imported files" +msgstr "आयातित फ़ाइलों पर नज़र रखें" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Offer to reload imported files when they change on disk" +msgstr "डिस्क पर बदलने पर आयातित फ़ाइलों को पुनः लोड करने का प्रस्ताव दें" + #: rayforge/ui_gtk/settings/general_preferences_page.py msgid "Startup behavior" msgstr "स्टार्टअप व्यवहार" @@ -3296,10 +3326,42 @@ msgstr "समूह" msgid "Ungroup" msgstr "समूह हटाएँ" +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +msgid "Break Apart" +msgstr "अलग करें" + +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +msgid "Close Paths…" +msgstr "पथ बंद करें…" + +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +msgid "Join Open Paths…" +msgstr "खुले पथ जोड़ें…" + +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +msgid "Delete Duplicates" +msgstr "डुप्लिकेट हटाएँ" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +msgid "Clean Up Paths" +msgstr "पथ साफ़ करें" + #: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/doceditor/stock_cmd.py msgid "Convert to Stock" msgstr "स्टॉक में बदलें" +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +#: rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Reload from Disk" +msgstr "डिस्क से पुनः लोड करें" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +#: rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Relink Source File…" +msgstr "स्रोत फ़ाइल पुनः लिंक करें…" + #: rayforge/ui_gtk/canvas2d/context_menu.py msgid "Add Tab Here" msgstr "यहाँ टैब जोड़ें" @@ -3366,6 +3428,61 @@ msgstr "स्टॉक जोड़ें" msgid "Auto Layout (Simple)" msgstr "स्वतः लेआउट (सरल)" +#: rayforge/ui_gtk/actions.py +#, python-brace-format +msgid "The file of {name} cannot be found. Use Relink to pick it." +msgstr "{name} की फ़ाइल नहीं मिल रही। इसे चुनने के लिए 'पुनः लिंक' का उपयोग करें।" + +#: rayforge/ui_gtk/actions.py +msgid "Nothing to break apart" +msgstr "अलग करने के लिए कुछ नहीं" + +#: rayforge/ui_gtk/actions.py +#, python-brace-format +msgid "Closed {count} path" +msgid_plural "Closed {count} paths" +msgstr[0] "{count} पथ बंद किया गया" +msgstr[1] "{count} पथ बंद किए गए" + +#: rayforge/ui_gtk/actions.py +msgid "Close Paths" +msgstr "पथ बंद करें" + +#: rayforge/ui_gtk/actions.py +msgid "" +"Closes open paths whose start and end point are closer than the tolerance." +msgstr "उन खुले पथों को बंद करता है जिनके आरंभ और अंत बिंदु टॉलरेंस से अधिक निकट हैं।" + +#: rayforge/ui_gtk/actions.py +#, python-brace-format +msgid "Made {count} join" +msgid_plural "Made {count} joins" +msgstr[0] "{count} जोड़ बनाया गया" +msgstr[1] "{count} जोड़ बनाए गए" + +#: rayforge/ui_gtk/actions.py +msgid "Join Open Paths" +msgstr "खुले पथ जोड़ें" + +#: rayforge/ui_gtk/actions.py +msgid "" +"Joins open paths whose end points are closer than the tolerance. Paths whose " +"ends then meet are closed." +msgstr "" +"उन खुले पथों को जोड़ता है जिनके अंत बिंदु टॉलरेंस से अधिक निकट हैं। जिन पथों के सिरे फिर मिल " +"जाते हैं, वे बंद कर दिए जाते हैं।" + +#: rayforge/ui_gtk/actions.py +msgid "Join Paths" +msgstr "पथ जोड़ें" + +#: rayforge/ui_gtk/actions.py +#, python-brace-format +msgid "Deleted {count} duplicate" +msgid_plural "Deleted {count} duplicates" +msgstr[0] "{count} डुप्लिकेट हटाया गया" +msgstr[1] "{count} डुप्लिकेट हटाए गए" + #: rayforge/ui_gtk/camera/lens_calibration_dialog.py #, python-brace-format msgid "{camera_name} - Lens Calibration" @@ -3458,6 +3575,7 @@ msgid "Temporal averaging, higher values cause trailing" msgstr "अस्थायी औसत, अधिक मान ट्रेलिंग का कारण बनते हैं" #: rayforge/ui_gtk/camera/image_settings_widget.py +#: rayforge/ui_gtk/camera/outside_view_widget.py msgid "Transparency" msgstr "पारदर्शिता" @@ -4186,6 +4304,42 @@ msgstr "कैमरे" msgid "Stream a camera image directly onto the work surface." msgstr "कैमरा छवि को सीधे कार्य सतह पर स्ट्रीम करें." +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "View Outside Workspace" +msgstr "कार्य क्षेत्र के बाहर का दृश्य" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "" +"Show the camera image in a margin around the work area to help position " +"workpieces. Requires image alignment." +msgstr "" +"वर्कपीस को स्थित करने में मदद के लिए कार्य क्षेत्र के चारों ओर एक मार्जिन में कैमरा छवि " +"दिखाएँ। छवि संरेखण आवश्यक है।" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Show Outside Workspace" +msgstr "कार्य क्षेत्र के बाहर दिखाएँ" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Draw the camera image around the work area" +msgstr "कार्य क्षेत्र के चारों ओर कैमरा छवि बनाएँ" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Margin" +msgstr "मार्जिन" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Size of the visible area on each side of the work area" +msgstr "कार्य क्षेत्र के प्रत्येक ओर दृश्य क्षेत्र का आकार" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Transparency outside the work area" +msgstr "कार्य क्षेत्र के बाहर पारदर्शिता" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Not shown until the camera image is aligned" +msgstr "कैमरा छवि संरेखित होने तक नहीं दिखाई जाती" + #: rayforge/ui_gtk/camera/alignment_widget.py msgid "" "Click the image to add reference points. Drag to move them.\n" @@ -5249,6 +5403,15 @@ msgstr "एकल अक्ष होमिंग की अनुमति द msgid "Enable individual axis homing controls in the jog dialog" msgstr "जॉग डायलॉग में व्यक्तिगत अक्ष होमिंग नियंत्रण सक्षम करें" +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Z Homing" +msgstr "Z होमिंग" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "" +"How the Z axis finds its reference: a home switch, or the auto-focus probe" +msgstr "Z अक्ष अपना संदर्भ कैसे ढूँढता है: होम स्विच से, या ऑटो-फ़ोकस प्रोब से" + #: rayforge/ui_gtk/machine/advanced_preferences_page.py msgid "Clear Alarm On Connect" msgstr "कनेक्ट पर अलार्म साफ़ करें" @@ -5691,6 +5854,44 @@ msgid "" msgstr "" "ये नोट्स इस मशीन के साथ सहेजे जाते हैं और डिवाइस प्रोफ़ाइल अपडेट द्वाराकभी नहीं बदले जाते।" +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "Offset" +msgstr "ऑफ़सेट" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "Positive values shift right-to-left passes to the right" +msgstr "सकारात्मक मान दाएँ-से-बाएँ पासों को दाईं ओर खिसकाते हैं" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "Remove Row" +msgstr "पंक्ति हटाएँ" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +#, python-brace-format +msgid "{speed}: {offset}" +msgstr "{speed}: {offset}" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "Bidirectional Scan Offset by Speed" +msgstr "गति के अनुसार द्विदिश स्कैन ऑफ़सेट" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "" +"Shift of right-to-left raster passes at each engrave speed, interpolated " +"between rows. Engrave steps use it when their own Bidirectional Scan Offset " +"is 0." +msgstr "" +"प्रत्येक उत्कीर्णन गति पर दाएँ-से-बाएँ रास्टर पासों का खिसकाव, पंक्तियों के बीच प्रक्षेपित। " +"उत्कीर्णन चरण इसका उपयोग तब करते हैं जब उनका अपना द्विदिश स्कैन ऑफ़सेट 0 हो।" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "Add Row" +msgstr "पंक्ति जोड़ें" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "No rows: all speeds use an offset of 0" +msgstr "कोई पंक्ति नहीं: सभी गतियाँ 0 का ऑफ़सेट उपयोग करती हैं" + #: rayforge/ui_gtk/machine/wizard_pages/camera_page.py msgid "" "Optional. Configure any cameras you want to use for preview and alignment." @@ -7467,6 +7668,10 @@ msgstr "हाल के खोलें" msgid "Import..." msgstr "आयात करें..." +#: rayforge/ui_gtk/main_menu.py +msgid "Relink Source File..." +msgstr "स्रोत फ़ाइल पुनः लिंक करें..." + #: rayforge/ui_gtk/main_menu.py msgid "Export G-code..." msgstr "G-code निर्यात करें..." @@ -7662,7 +7867,7 @@ msgstr "व्यवस्थित करें" msgid "_Tools" msgstr "_उपकरण" -#: rayforge/ui_gtk/main_menu.py +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/doceditor/frame_popover.py msgid "Frame" msgstr "फ़्रेम करें" @@ -7897,79 +8102,79 @@ msgstr "एक अप्रत्याशित त्रुटि हुई: { #: rayforge/ui_gtk/gestures/model.py msgid "Click" -msgstr "" +msgstr "क्लिक" #: rayforge/ui_gtk/gestures/model.py msgid "Double-click" -msgstr "" +msgstr "डबल-क्लिक" #: rayforge/ui_gtk/gestures/model.py msgid "Drag" -msgstr "" +msgstr "खींचें" #: rayforge/ui_gtk/gestures/model.py msgid "Scroll" -msgstr "" +msgstr "स्क्रॉल" #: rayforge/ui_gtk/gestures/model.py msgid "Middle" -msgstr "" +msgstr "मध्य" #: rayforge/ui_gtk/gestures/model.py msgid "Shift" -msgstr "" +msgstr "Shift" #: rayforge/ui_gtk/gestures/model.py msgid "Ctrl" -msgstr "" +msgstr "Ctrl" #: rayforge/ui_gtk/gestures/model.py msgid "Alt" -msgstr "" +msgstr "Alt" #: rayforge/ui_gtk/gestures/builtin.py msgid "2D Canvas" -msgstr "" +msgstr "2D कैनवास" #: rayforge/ui_gtk/gestures/builtin.py msgid "Navigation gestures on the 2D work surface." -msgstr "" +msgstr "2D कार्य सतह पर नेविगेशन इशारे।" #: rayforge/ui_gtk/gestures/builtin.py msgid "3D Canvas" -msgstr "" +msgstr "3D कैनवास" #: rayforge/ui_gtk/gestures/builtin.py msgid "Navigation gestures on the 3D preview." -msgstr "" +msgstr "3D पूर्वावलोकन में नेविगेशन इशारे।" #: rayforge/ui_gtk/gestures/builtin.py msgid "Pan the view" -msgstr "" +msgstr "व्यू को पैन करें" #: rayforge/ui_gtk/gestures/builtin.py msgid "Hold the mouse button down and move to pan the view." -msgstr "" +msgstr "व्यू को पैन करने के लिए माउस बटन दबाकर रखें और हिलाएँ।" #: rayforge/ui_gtk/gestures/builtin.py msgid "Orbit the camera" -msgstr "" +msgstr "कैमरे की परिक्रमा करें" #: rayforge/ui_gtk/gestures/builtin.py msgid "Hold the mouse button down and move to orbit around the scene." -msgstr "" +msgstr "दृश्य के चारों ओर परिक्रमा करने के लिए माउस बटन दबाकर रखें और हिलाएँ।" #: rayforge/ui_gtk/gestures/builtin.py msgid "Pan the camera" -msgstr "" +msgstr "कैमरे को पैन करें" #: rayforge/ui_gtk/gestures/builtin.py msgid "Hold the mouse button down and move to pan the scene." -msgstr "" +msgstr "दृश्य को पैन करने के लिए माउस बटन दबाकर रखें और हिलाएँ।" #: rayforge/ui_gtk/gestures/builtin.py msgid "Rotate around the Z axis" -msgstr "" +msgstr "Z अक्ष के चारों ओर घुमाएँ" #: rayforge/ui_gtk/project_cmd.py msgid "Unsaved Changes" @@ -8437,10 +8642,23 @@ msgstr "नई लाइब्रेरी फ़ोल्डर के लि msgid "Failed to create library. A folder with that name may already exist." msgstr "लाइब्रेरी बनाने में विफलता. उस नाम का फ़ोल्डर पहले से मौजूद हो सकता है." +#: rayforge/ui_gtk/doceditor/frame_popover.py +msgid "Include Overscan Margin" +msgstr "ओवरस्कैन मार्जिन शामिल करें" + +#: rayforge/ui_gtk/doceditor/frame_popover.py +msgid "Frame the full machine path, including the extra engraving travel" +msgstr "अतिरिक्त उत्कीर्णन यात्रा सहित मशीन के पूरे पथ का फ़्रेम बनाएं" + #: rayforge/ui_gtk/doceditor/file_dialogs.py msgid "Open File" msgstr "फ़ाइल खोलें" +#: rayforge/ui_gtk/doceditor/file_dialogs.py +#, python-brace-format +msgid "Relink “{name}”" +msgstr "“{name}” को पुनः लिंक करें" + #: rayforge/ui_gtk/doceditor/file_dialogs.py msgid "All supported" msgstr "सभी समर्थित" @@ -8561,6 +8779,10 @@ msgstr "कस्टम" msgid "Add New Layer" msgstr "नई लेयर जोड़ें" +#: rayforge/ui_gtk/doceditor/path_tolerance_dialog.py +msgid "Tolerance" +msgstr "टॉलरेंस" + #: rayforge/ui_gtk/doceditor/stock_properties_dialog.py #: rayforge/ui_gtk/doceditor/item_properties.py msgid "Stock Properties" @@ -8696,6 +8918,42 @@ msgstr "Zero Here सेट करने के लिए मशीन का msgid "Set current position as 0" msgstr "वर्तमान स्थिति को 0 सेट करें" +#: rayforge/ui_gtk/doceditor/source_changed_dialog.py +msgid "Imported File Changed" +msgid_plural "Imported Files Changed" +msgstr[0] "आयातित फ़ाइल बदली गई" +msgstr[1] "आयातित फ़ाइलें बदली गईं" + +#: rayforge/ui_gtk/doceditor/source_changed_dialog.py +#, python-brace-format +msgid "" +"\"{name}\" was changed on disk. Reload it? Position, size and settings are " +"kept." +msgstr "" +"\"{name}\" डिस्क पर बदल गई। इसे पुनः लोड करें? स्थिति, आकार और सेटिंग्स बनी रहती हैं।" + +#: rayforge/ui_gtk/doceditor/source_changed_dialog.py +msgid "" +"These imported files were changed on disk. Reload them? Position, size and " +"settings are kept." +msgstr "" +"ये आयातित फ़ाइलें डिस्क पर बदल गईं। इन्हें पुनः लोड करें? स्थिति, आकार और सेटिंग्स बनी रहती " +"हैं।" + +#: rayforge/ui_gtk/doceditor/source_changed_dialog.py +msgid "_Ignore" +msgstr "_अनदेखा करें" + +#: rayforge/ui_gtk/doceditor/source_changed_dialog.py +msgid "_Reload" +msgstr "_पुनः लोड करें" + +#: rayforge/ui_gtk/doceditor/source_changed_dialog.py +msgid "Always reload this file without asking" +msgid_plural "Always reload the selected files without asking" +msgstr[0] "इस फ़ाइल को बिना पूछे हमेशा पुनः लोड करें" +msgstr[1] "चयनित फ़ाइलों को बिना पूछे हमेशा पुनः लोड करें" + #: rayforge/ui_gtk/doceditor/step_type_selection_dialog.py msgid "Select Step Types" msgstr "स्टेप प्रकार चुनें" @@ -9910,6 +10168,75 @@ msgstr "वर्कपीस पुनर्व्यवस्थित कर msgid "Reorder items" msgstr "आइटम पुनर्व्यवस्थित करें" +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "{name} ({x:.1f}, {y:.1f} mm)" +msgstr "{name} ({x:.1f}, {y:.1f} mm)" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "Could not reload {name}: {error}" +msgstr "{name} को पुनः लोड नहीं किया जा सका: {error}" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "Reloaded {name}" +msgstr "{name} पुनः लोड किया गया" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "{n} new element added" +msgid_plural "{n} new elements added" +msgstr[0] "{n} नया तत्व जोड़ा गया" +msgstr[1] "{n} नए तत्व जोड़े गए" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "{n} element no longer in the file was removed" +msgid_plural "{n} elements no longer in the file were removed" +msgstr[0] "{n} तत्व जो अब फ़ाइल में नहीं है, हटा दिया गया" +msgstr[1] "{n} तत्व जो अब फ़ाइल में नहीं हैं, हटा दिए गए" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "{n} edited element was left unchanged" +msgid_plural "{n} edited elements were left unchanged" +msgstr[0] "{n} संपादित तत्व अपरिवर्तित छोड़ा गया" +msgstr[1] "{n} संपादित तत्व अपरिवर्तित छोड़े गए" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "and {n} more" +msgstr "और {n} अधिक" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "" +"{n} tab more than {limit:.1f} mm from the new outline was removed: {places}" +msgid_plural "" +"{n} tabs more than {limit:.1f} mm from the new outline were removed: {places}" +msgstr[0] "" +"{n} टैब जो नई रूपरेखा से {limit:.1f} मिमी से अधिक दूर था, हटा दिया गया: {places}" +msgstr[1] "" +"{n} टैब जो नई रूपरेखा से {limit:.1f} मिमी से अधिक दूर थे, हटा दिए गए: {places}" + +#: rayforge/doceditor/reload_cmd.py +msgid "the file type is not known" +msgstr "फ़ाइल प्रकार ज्ञात नहीं है" + +#: rayforge/doceditor/reload_cmd.py +msgid "the file could not be read" +msgstr "फ़ाइल पढ़ी नहीं जा सकी" + +#: rayforge/doceditor/reload_cmd.py +msgid "the file contains nothing to import" +msgstr "फ़ाइल में आयात करने के लिए कुछ नहीं है" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "Reload {name}" +msgstr "{name} पुनः लोड करें" + #: rayforge/doceditor/array_cmd.py msgid "Create Array" msgstr "ऐरे बनाएँ" @@ -9918,6 +10245,22 @@ msgstr "ऐरे बनाएँ" msgid "Create array copy" msgstr "ऐरे प्रतिलिपि बनाएँ" +#: rayforge/doceditor/path_cleanup_cmd.py +msgid "Close paths" +msgstr "पथ बंद करें" + +#: rayforge/doceditor/path_cleanup_cmd.py +msgid "Join paths" +msgstr "पथ जोड़ें" + +#: rayforge/doceditor/path_cleanup_cmd.py +msgid "Delete duplicates" +msgstr "डुप्लिकेट हटाएँ" + +#: rayforge/doceditor/path_cleanup_cmd.py +msgid "Break apart" +msgstr "अलग करें" + #: rayforge/doceditor/editor.py #, python-brace-format msgid "{count} asset(s) require disabled addon '{addon}'" @@ -9995,11 +10338,27 @@ msgstr "बिंदु पर स्थिति" msgid "Auto Layout" msgstr "स्वतः लेआउट" +#: rayforge/image/hpgl/importer.py +#, python-brace-format +msgid "Some HPGL instructions are not supported and were skipped: {names}" +msgstr "कुछ HPGL निर्देश समर्थित नहीं हैं और छोड़ दिए गए: {names}" + +#: rayforge/image/hpgl/importer.py +#, python-brace-format +msgid "{filename} contains no HPGL drawing commands." +msgstr "{filename} में कोई HPGL ड्रॉइंग कमांड नहीं है." + +#: rayforge/image/hpgl/importer.py +#, python-brace-format +msgid "Pen {number}" +msgstr "पेन {number}" + #: rayforge/image/png/importer.py msgid "Failed to scan PNG file: {}" msgstr "PNG फ़ाइल स्कैन करने में विफलता: {}" -#: rayforge/image/png/importer.py rayforge/image/jpg/importer.py +#: rayforge/image/png/importer.py rayforge/image/bitmap/importer.py +#: rayforge/image/jpg/importer.py msgid "Failed to process image data." msgstr "छवि डेटा प्रोसेस करने में विफलता." @@ -10035,6 +10394,15 @@ msgstr "ट्रेसिंग के लिए SVG रास्टराइ msgid "Failed to normalize image data." msgstr "छवि डेटा सामान्यीकृत करने में विफलता." +#: rayforge/image/svg/importer.py +msgid "This SVGZ file's gzip data could not be decompressed." +msgstr "" + +#: rayforge/image/bitmap/importer.py +#, python-brace-format +msgid "Could not read the {format} image: {error}" +msgstr "{format} छवि पढ़ी नहीं जा सकी: {error}" + #: rayforge/image/pdf/pdf_trace.py rayforge/image/pdf/pdf_vector.py msgid "PDF file contains no pages." msgstr "PDF फ़ाइल में कोई पृष्ठ नहीं है." @@ -10140,57 +10508,60 @@ msgid "Floyd Steinberg" msgstr "Floyd Steinberg" #: rayforge/image/dither.py -msgid "Bayer 2" -msgstr "Bayer 2" +msgid "Atkinson" +msgstr "Atkinson" #: rayforge/image/dither.py -msgid "Bayer 4" -msgstr "Bayer 4" +msgid "Stucki" +msgstr "Stucki" #: rayforge/image/dither.py -msgid "Bayer 8" -msgstr "Bayer 8" +msgid "Jarvis, Judice & Ninke" +msgstr "Jarvis, Judice & Ninke" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "View Outside Workspace" -msgstr "कार्य क्षेत्र के बाहर का दृश्य" +#: rayforge/image/dither.py +msgid "Sierra" +msgstr "Sierra" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Show the camera image in a margin around the work area to help position workpieces. Requires image alignment." -msgstr "वर्कपीस को स्थित करने में मदद के लिए कार्य क्षेत्र के चारों ओर एक मार्जिन में कैमरा छवि दिखाएँ। छवि संरेखण आवश्यक है।" +#: rayforge/image/dither.py +msgid "Sierra 2-Row" +msgstr "Sierra 2-Row" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Show Outside Workspace" -msgstr "कार्य क्षेत्र के बाहर दिखाएँ" +#: rayforge/image/dither.py +msgid "Sierra Lite" +msgstr "Sierra Lite" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Draw the camera image around the work area" -msgstr "कार्य क्षेत्र के चारों ओर कैमरा छवि बनाएँ" +#: rayforge/image/dither.py +msgid "Burkes" +msgstr "Burkes" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Not shown until the camera image is aligned" -msgstr "कैमरा छवि संरेखित होने तक नहीं दिखाई जाती" +#: rayforge/image/dither.py +msgid "Bayer 2" +msgstr "Bayer 2" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Margin" -msgstr "मार्जिन" +#: rayforge/image/dither.py +msgid "Bayer 4" +msgstr "Bayer 4" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Size of the visible area on each side of the work area" -msgstr "कार्य क्षेत्र के प्रत्येक ओर दृश्य क्षेत्र का आकार" +#: rayforge/image/dither.py +msgid "Bayer 8" +msgstr "Bayer 8" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Transparency outside the work area" -msgstr "कार्य क्षेत्र के बाहर पारदर्शिता" +#: rayforge/image/dither.py +msgid "Newsprint" +msgstr "न्यूज़प्रिंट" -#: rayforge/image/bitmap/importer.py -#, python-brace-format -msgid "Could not read the {format} image: {error}" -msgstr "{format} छवि पढ़ी नहीं जा सकी: {error}" +#: rayforge/image/dither.py +msgid "Halftone" +msgstr "हाफ़टोन" #: rayforge/image/postscript/importer.py -msgid "Importing EPS and PostScript files requires Ghostscript, which was not found. Install Ghostscript and try again." -msgstr "EPS और PostScript फ़ाइलें आयात करने के लिए Ghostscript ज़रूरी है, जो नहीं मिला. Ghostscript इंस्टॉल करें और फिर से कोशिश करें." +msgid "" +"Importing EPS and PostScript files requires Ghostscript, which was not " +"found. Install Ghostscript and try again." +msgstr "" +"EPS और PostScript फ़ाइलें आयात करने के लिए Ghostscript ज़रूरी है, जो नहीं मिला. " +"Ghostscript इंस्टॉल करें और फिर से कोशिश करें." #: rayforge/image/postscript/importer.py #, python-brace-format @@ -10198,47 +10569,11 @@ msgid "Ghostscript could not convert {filename}: {error}" msgstr "Ghostscript {filename} को परिवर्तित नहीं कर सका: {error}" #: rayforge/image/postscript/importer.py -msgid "This Illustrator file was saved without PDF compatibility. Rayforge can only read it with Ghostscript, which was not found. Install Ghostscript, or save the file again in Illustrator with \"Create PDF Compatible File\" enabled." -msgstr "यह Illustrator फ़ाइल PDF संगतता के बिना सहेजी गई थी. Rayforge इसे केवल Ghostscript से पढ़ सकता है, जो नहीं मिला. Ghostscript इंस्टॉल करें, या Illustrator में \"Create PDF Compatible File\" चालू करके फ़ाइल फिर से सहेजें." - -#: rayforge/image/hpgl/importer.py -#, python-brace-format -msgid "Some HPGL instructions are not supported and were skipped: {names}" -msgstr "कुछ HPGL निर्देश समर्थित नहीं हैं और छोड़ दिए गए: {names}" - -#: rayforge/image/hpgl/importer.py -#, python-brace-format -msgid "{filename} contains no HPGL drawing commands." -msgstr "{filename} में कोई HPGL ड्रॉइंग कमांड नहीं है." - -#: rayforge/image/hpgl/importer.py -#, python-brace-format -msgid "Pen {number}" -msgstr "पेन {number}" - -#: rayforge/machine/driver/driver.py -msgid "Home Switch" -msgstr "होम स्विच" - -#: rayforge/machine/driver/driver.py -msgid "Probe" -msgstr "प्रोब" - -#: rayforge/ui_gtk/machine/advanced_preferences_page.py -msgid "Z Homing" -msgstr "Z होमिंग" - -#: rayforge/ui_gtk/machine/advanced_preferences_page.py -msgid "" -"How the Z axis finds its reference: a home switch, " -"or the auto-focus probe" -msgstr "Z अक्ष अपना संदर्भ कैसे ढूँढता है: होम स्विच से, या ऑटो-फ़ोकस प्रोब से" - -#: rayforge/ui_gtk/doceditor/frame_popover.py -msgid "Include Overscan Margin" -msgstr "ओवरस्कैन मार्जिन शामिल करें" - -#: rayforge/ui_gtk/doceditor/frame_popover.py msgid "" -"Frame the full machine path, including the extra engraving travel" -msgstr "अतिरिक्त उत्कीर्णन यात्रा सहित मशीन के पूरे पथ का फ़्रेम बनाएं" +"This Illustrator file was saved without PDF compatibility. Rayforge can only " +"read it with Ghostscript, which was not found. Install Ghostscript, or save " +"the file again in Illustrator with \"Create PDF Compatible File\" enabled." +msgstr "" +"यह Illustrator फ़ाइल PDF संगतता के बिना सहेजी गई थी. Rayforge इसे केवल Ghostscript " +"से पढ़ सकता है, जो नहीं मिला. Ghostscript इंस्टॉल करें, या Illustrator में \"Create " +"PDF Compatible File\" चालू करके फ़ाइल फिर से सहेजें." diff --git a/rayforge/locale/pt/LC_MESSAGES/rayforge.po b/rayforge/locale/pt/LC_MESSAGES/rayforge.po index 032cd596f..89c003847 100644 --- a/rayforge/locale/pt/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/pt/LC_MESSAGES/rayforge.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: Rayforge\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-10-06 22:41+0200\n" +"POT-Creation-Date: 2026-10-15 02:25+0200\n" "PO-Revision-Date: 2025-07-24 22:09+0200\n" "Last-Translator: Samuel Abels \n" "Language-Team: Portuguese \n" @@ -442,6 +442,18 @@ msgstr "" "Qual transporte usar. Automático abre o USB quando disponível e recorre à " "rede." +#: rayforge/machine/driver/ruidarpa/rpa_adapter.py +msgid "TCP protocol" +msgstr "Protocolo TCP" + +#: rayforge/machine/driver/ruidarpa/rpa_adapter.py +msgid "" +"Use TCP instead of UDP for the network connection. Needed by controllers " +"that do not respond over UDP." +msgstr "" +"Usar TCP em vez de UDP para a conexão de rede. Necessário para controladores " +"que não respondem por UDP." + #: rayforge/machine/driver/ruidarpa/rpa_adapter.py #: rayforge/machine/driver/octoprint/octoprint_driver.py #: rayforge/machine/driver/grbl/grbl_telnet.py @@ -624,6 +636,14 @@ msgstr "" "Este driver é experimental e quase certamente contém erros. Pode não " "funcionar de forma confiável. Use por sua conta e risco." +#: rayforge/machine/driver/driver.py +msgid "Home Switch" +msgstr "Interruptor de Referência" + +#: rayforge/machine/driver/driver.py +msgid "Probe" +msgstr "Sonda" + #: rayforge/machine/driver/driver.py rayforge/machine/transport/transport.py #: rayforge/ui_gtk/machine/machine_dropdown.py #: rayforge/ui_gtk/machine/status_widget.py @@ -2669,6 +2689,7 @@ msgstr "" #: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py #: rayforge/ui_gtk/doceditor/material_library_list.py #: rayforge/ui_gtk/doceditor/add_material_dialog.py +#: rayforge/ui_gtk/doceditor/path_tolerance_dialog.py #: rayforge/ui_gtk/doceditor/step_type_selection_dialog.py #: rayforge/ui_gtk/doceditor/material_selector.py #: rayforge/ui_gtk/doceditor/material_list.py @@ -2899,16 +2920,16 @@ msgstr "Instalar, atualizar e remover extensões." #: rayforge/ui_gtk/settings/gesture_preferences_page.py msgid "Mouse Gestures" -msgstr "" +msgstr "Gestos do mouse" #: rayforge/ui_gtk/settings/gesture_preferences_page.py #, python-format msgid "Default: %s" -msgstr "" +msgstr "Padrão: %s" #: rayforge/ui_gtk/settings/gesture_preferences_page.py msgid "Unassigned" -msgstr "" +msgstr "Não atribuído" #: rayforge/ui_gtk/settings/recipe_manager_page.py msgid "Recipes" @@ -3188,6 +3209,7 @@ msgid "Length" msgstr "Comprimento" #: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/machine/bidir_offset_table.py #: rayforge/ui_gtk/doceditor/bottom_panel.py msgid "Speed" msgstr "Velocidade" @@ -3224,6 +3246,14 @@ msgstr "Verificar atualizações" msgid "Automatically check for new Rayforge versions on startup" msgstr "Verificar automaticamente novas versões do Rayforge na inicialização" +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Watch imported files" +msgstr "Monitorar arquivos importados" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Offer to reload imported files when they change on disk" +msgstr "Oferecer recarregar os arquivos importados quando mudarem no disco" + #: rayforge/ui_gtk/settings/general_preferences_page.py msgid "Startup behavior" msgstr "Comportamento de inicialização" @@ -3402,10 +3432,42 @@ msgstr "Agrupar" msgid "Ungroup" msgstr "Desagrupar" +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +msgid "Break Apart" +msgstr "Separar" + +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +msgid "Close Paths…" +msgstr "Fechar caminhos…" + +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +msgid "Join Open Paths…" +msgstr "Unir caminhos abertos…" + +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +msgid "Delete Duplicates" +msgstr "Excluir duplicatas" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +msgid "Clean Up Paths" +msgstr "Limpar caminhos" + #: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/doceditor/stock_cmd.py msgid "Convert to Stock" msgstr "Converter em material" +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +#: rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Reload from Disk" +msgstr "Recarregar do disco" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +#: rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Relink Source File…" +msgstr "Revincular arquivo de origem…" + #: rayforge/ui_gtk/canvas2d/context_menu.py msgid "Add Tab Here" msgstr "Adicionar Aba de Fixação Aqui" @@ -3472,6 +3534,64 @@ msgstr "Adicionar Material" msgid "Auto Layout (Simple)" msgstr "Layout automático (Simples)" +#: rayforge/ui_gtk/actions.py +#, python-brace-format +msgid "The file of {name} cannot be found. Use Relink to pick it." +msgstr "" +"O arquivo de {name} não foi encontrado. Use «Revincular» para selecioná-lo." + +#: rayforge/ui_gtk/actions.py +msgid "Nothing to break apart" +msgstr "Nada para separar" + +#: rayforge/ui_gtk/actions.py +#, python-brace-format +msgid "Closed {count} path" +msgid_plural "Closed {count} paths" +msgstr[0] "{count} caminho fechado" +msgstr[1] "{count} caminhos fechados" + +#: rayforge/ui_gtk/actions.py +msgid "Close Paths" +msgstr "Fechar caminhos" + +#: rayforge/ui_gtk/actions.py +msgid "" +"Closes open paths whose start and end point are closer than the tolerance." +msgstr "" +"Fecha os caminhos abertos cujos pontos inicial e final estão mais próximos " +"do que a tolerância." + +#: rayforge/ui_gtk/actions.py +#, python-brace-format +msgid "Made {count} join" +msgid_plural "Made {count} joins" +msgstr[0] "{count} junção feita" +msgstr[1] "{count} junções feitas" + +#: rayforge/ui_gtk/actions.py +msgid "Join Open Paths" +msgstr "Unir caminhos abertos" + +#: rayforge/ui_gtk/actions.py +msgid "" +"Joins open paths whose end points are closer than the tolerance. Paths whose " +"ends then meet are closed." +msgstr "" +"Une os caminhos abertos cujos pontos finais estão mais próximos do que a " +"tolerância. Os caminhos cujas extremidades se encontram são fechados." + +#: rayforge/ui_gtk/actions.py +msgid "Join Paths" +msgstr "Unir caminhos" + +#: rayforge/ui_gtk/actions.py +#, python-brace-format +msgid "Deleted {count} duplicate" +msgid_plural "Deleted {count} duplicates" +msgstr[0] "{count} duplicata excluída" +msgstr[1] "{count} duplicatas excluídas" + #: rayforge/ui_gtk/camera/lens_calibration_dialog.py #, python-brace-format msgid "{camera_name} - Lens Calibration" @@ -3566,6 +3686,7 @@ msgid "Temporal averaging, higher values cause trailing" msgstr "Média temporal, valores altos causam arrasto" #: rayforge/ui_gtk/camera/image_settings_widget.py +#: rayforge/ui_gtk/camera/outside_view_widget.py msgid "Transparency" msgstr "Transparência" @@ -4304,6 +4425,42 @@ msgid "Stream a camera image directly onto the work surface." msgstr "" "Transmita uma imagem da câmera diretamente para a superfície de trabalho." +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "View Outside Workspace" +msgstr "Vista fora da área de trabalho" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "" +"Show the camera image in a margin around the work area to help position " +"workpieces. Requires image alignment." +msgstr "" +"Mostra a imagem da câmera em uma margem ao redor da área de trabalho para " +"ajudar a posicionar as peças. Requer o alinhamento da imagem." + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Show Outside Workspace" +msgstr "Mostrar fora da área de trabalho" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Draw the camera image around the work area" +msgstr "Desenhar a imagem da câmera ao redor da área de trabalho" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Margin" +msgstr "Margem" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Size of the visible area on each side of the work area" +msgstr "Tamanho da área visível em cada lado da área de trabalho" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Transparency outside the work area" +msgstr "Transparência fora da área de trabalho" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Not shown until the camera image is aligned" +msgstr "Não é mostrada até que a imagem da câmera esteja alinhada" + #: rayforge/ui_gtk/camera/alignment_widget.py msgid "" "Click the image to add reference points. Drag to move them.\n" @@ -5406,6 +5563,17 @@ msgid "Enable individual axis homing controls in the jog dialog" msgstr "" "Ativar controlos individuais de referenciação de eixos no diálogo de jog" +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Z Homing" +msgstr "Referenciação de Z" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "" +"How the Z axis finds its reference: a home switch, or the auto-focus probe" +msgstr "" +"Como o eixo Z encontra sua referência: um interruptor de referência ou a " +"sonda de foco automático" + #: rayforge/ui_gtk/machine/advanced_preferences_page.py msgid "Clear Alarm On Connect" msgstr "Limpar Alarme ao Conectar" @@ -5855,6 +6023,47 @@ msgstr "" "Estas notas são guardadas com esta máquina e nunca são substituídas por " "atualizações do perfil do dispositivo." +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "Offset" +msgstr "Deslocamento" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "Positive values shift right-to-left passes to the right" +msgstr "" +"Valores positivos deslocam as passagens da direita para a esquerda para a " +"direita" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "Remove Row" +msgstr "Remover linha" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +#, python-brace-format +msgid "{speed}: {offset}" +msgstr "{speed}: {offset}" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "Bidirectional Scan Offset by Speed" +msgstr "Deslocamento de varredura bidirecional por velocidade" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "" +"Shift of right-to-left raster passes at each engrave speed, interpolated " +"between rows. Engrave steps use it when their own Bidirectional Scan Offset " +"is 0." +msgstr "" +"Deslocamento das passagens de raster da direita para a esquerda em cada " +"velocidade de gravação, interpolado entre as linhas. As etapas de gravação o " +"usam quando seu próprio deslocamento de varredura bidirecional é 0." + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "Add Row" +msgstr "Adicionar linha" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "No rows: all speeds use an offset of 0" +msgstr "Nenhuma linha: todas as velocidades usam um deslocamento de 0" + #: rayforge/ui_gtk/machine/wizard_pages/camera_page.py msgid "" "Optional. Configure any cameras you want to use for preview and alignment." @@ -7677,6 +7886,10 @@ msgstr "Abrir recente" msgid "Import..." msgstr "Importar..." +#: rayforge/ui_gtk/main_menu.py +msgid "Relink Source File..." +msgstr "Revincular arquivo de origem..." + #: rayforge/ui_gtk/main_menu.py msgid "Export G-code..." msgstr "Exportar G-code..." @@ -7872,7 +8085,7 @@ msgstr "Organizar" msgid "_Tools" msgstr "_Ferramentas" -#: rayforge/ui_gtk/main_menu.py +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/doceditor/frame_popover.py msgid "Frame" msgstr "Enquadrar" @@ -8114,79 +8327,81 @@ msgstr "Ocorreu um erro inesperado: {error}" #: rayforge/ui_gtk/gestures/model.py msgid "Click" -msgstr "" +msgstr "Clique" #: rayforge/ui_gtk/gestures/model.py msgid "Double-click" -msgstr "" +msgstr "Clique duplo" #: rayforge/ui_gtk/gestures/model.py msgid "Drag" -msgstr "" +msgstr "Arrastar" #: rayforge/ui_gtk/gestures/model.py msgid "Scroll" -msgstr "" +msgstr "Rolar" #: rayforge/ui_gtk/gestures/model.py msgid "Middle" -msgstr "" +msgstr "Meio" #: rayforge/ui_gtk/gestures/model.py msgid "Shift" -msgstr "" +msgstr "Shift" #: rayforge/ui_gtk/gestures/model.py msgid "Ctrl" -msgstr "" +msgstr "Ctrl" #: rayforge/ui_gtk/gestures/model.py msgid "Alt" -msgstr "" +msgstr "Alt" #: rayforge/ui_gtk/gestures/builtin.py msgid "2D Canvas" -msgstr "" +msgstr "Tela 2D" #: rayforge/ui_gtk/gestures/builtin.py msgid "Navigation gestures on the 2D work surface." -msgstr "" +msgstr "Gestos de navegação na superfície de trabalho 2D." #: rayforge/ui_gtk/gestures/builtin.py msgid "3D Canvas" -msgstr "" +msgstr "Tela 3D" #: rayforge/ui_gtk/gestures/builtin.py msgid "Navigation gestures on the 3D preview." -msgstr "" +msgstr "Gestos de navegação na pré-visualização 3D." #: rayforge/ui_gtk/gestures/builtin.py msgid "Pan the view" -msgstr "" +msgstr "Deslocar a visualização" #: rayforge/ui_gtk/gestures/builtin.py msgid "Hold the mouse button down and move to pan the view." msgstr "" +"Mantenha o botão do mouse pressionado e mova para deslocar a visualização." #: rayforge/ui_gtk/gestures/builtin.py msgid "Orbit the camera" -msgstr "" +msgstr "Orbitar a câmera" #: rayforge/ui_gtk/gestures/builtin.py msgid "Hold the mouse button down and move to orbit around the scene." msgstr "" +"Mantenha o botão do mouse pressionado e mova para orbitar ao redor da cena." #: rayforge/ui_gtk/gestures/builtin.py msgid "Pan the camera" -msgstr "" +msgstr "Deslocar a câmera" #: rayforge/ui_gtk/gestures/builtin.py msgid "Hold the mouse button down and move to pan the scene." -msgstr "" +msgstr "Mantenha o botão do mouse pressionado e mova para deslocar a cena." #: rayforge/ui_gtk/gestures/builtin.py msgid "Rotate around the Z axis" -msgstr "" +msgstr "Girar em torno do eixo Z" #: rayforge/ui_gtk/project_cmd.py msgid "Unsaved Changes" @@ -8660,10 +8875,24 @@ msgstr "Insira um nome para a nova pasta da biblioteca:" msgid "Failed to create library. A folder with that name may already exist." msgstr "Falha ao criar biblioteca. Uma pasta com esse nome pode já existir." +#: rayforge/ui_gtk/doceditor/frame_popover.py +msgid "Include Overscan Margin" +msgstr "Incluir a margem de overscan" + +#: rayforge/ui_gtk/doceditor/frame_popover.py +msgid "Frame the full machine path, including the extra engraving travel" +msgstr "" +"Moldura o caminho completo da máquina, incluindo o trajeto extra de gravação" + #: rayforge/ui_gtk/doceditor/file_dialogs.py msgid "Open File" msgstr "Abrir Arquivo" +#: rayforge/ui_gtk/doceditor/file_dialogs.py +#, python-brace-format +msgid "Relink “{name}”" +msgstr "Revincular “{name}”" + #: rayforge/ui_gtk/doceditor/file_dialogs.py msgid "All supported" msgstr "Todos os suportados" @@ -8784,6 +9013,10 @@ msgstr "Personalizado" msgid "Add New Layer" msgstr "Adicionar Nova Camada" +#: rayforge/ui_gtk/doceditor/path_tolerance_dialog.py +msgid "Tolerance" +msgstr "Tolerância" + #: rayforge/ui_gtk/doceditor/stock_properties_dialog.py #: rayforge/ui_gtk/doceditor/item_properties.py msgid "Stock Properties" @@ -8923,6 +9156,43 @@ msgstr "A máquina deve estar conectada para definir o zero aqui" msgid "Set current position as 0" msgstr "Definir a posição atual como 0" +#: rayforge/ui_gtk/doceditor/source_changed_dialog.py +msgid "Imported File Changed" +msgid_plural "Imported Files Changed" +msgstr[0] "Arquivo importado alterado" +msgstr[1] "Arquivos importados alterados" + +#: rayforge/ui_gtk/doceditor/source_changed_dialog.py +#, python-brace-format +msgid "" +"\"{name}\" was changed on disk. Reload it? Position, size and settings are " +"kept." +msgstr "" +"\"{name}\" foi alterado no disco. Recarregar? A posição, o tamanho e as " +"configurações são mantidos." + +#: rayforge/ui_gtk/doceditor/source_changed_dialog.py +msgid "" +"These imported files were changed on disk. Reload them? Position, size and " +"settings are kept." +msgstr "" +"Estes arquivos importados foram alterados no disco. Recarregá-los? A " +"posição, o tamanho e as configurações são mantidos." + +#: rayforge/ui_gtk/doceditor/source_changed_dialog.py +msgid "_Ignore" +msgstr "_Ignorar" + +#: rayforge/ui_gtk/doceditor/source_changed_dialog.py +msgid "_Reload" +msgstr "_Recarregar" + +#: rayforge/ui_gtk/doceditor/source_changed_dialog.py +msgid "Always reload this file without asking" +msgid_plural "Always reload the selected files without asking" +msgstr[0] "Sempre recarregar este arquivo sem perguntar" +msgstr[1] "Sempre recarregar os arquivos selecionados sem perguntar" + #: rayforge/ui_gtk/doceditor/step_type_selection_dialog.py msgid "Select Step Types" msgstr "Selecionar tipos de etapa" @@ -10156,6 +10426,77 @@ msgstr "Reordenar peças" msgid "Reorder items" msgstr "Reordenar itens" +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "{name} ({x:.1f}, {y:.1f} mm)" +msgstr "{name} ({x:.1f}, {y:.1f} mm)" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "Could not reload {name}: {error}" +msgstr "Não foi possível recarregar {name}: {error}" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "Reloaded {name}" +msgstr "{name} recarregado" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "{n} new element added" +msgid_plural "{n} new elements added" +msgstr[0] "{n} novo elemento adicionado" +msgstr[1] "{n} novos elementos adicionados" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "{n} element no longer in the file was removed" +msgid_plural "{n} elements no longer in the file were removed" +msgstr[0] "{n} elemento que não está mais no arquivo foi removido" +msgstr[1] "{n} elementos que não estão mais no arquivo foram removidos" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "{n} edited element was left unchanged" +msgid_plural "{n} edited elements were left unchanged" +msgstr[0] "{n} elemento editado foi deixado inalterado" +msgstr[1] "{n} elementos editados foram deixados inalterados" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "and {n} more" +msgstr "e mais {n}" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "" +"{n} tab more than {limit:.1f} mm from the new outline was removed: {places}" +msgid_plural "" +"{n} tabs more than {limit:.1f} mm from the new outline were removed: {places}" +msgstr[0] "" +"{n} aba de fixação a mais de {limit:.1f} mm do novo contorno foi removida: " +"{places}" +msgstr[1] "" +"{n} abas de fixação a mais de {limit:.1f} mm do novo contorno foram " +"removidas: {places}" + +#: rayforge/doceditor/reload_cmd.py +msgid "the file type is not known" +msgstr "o tipo de arquivo é desconhecido" + +#: rayforge/doceditor/reload_cmd.py +msgid "the file could not be read" +msgstr "o arquivo não pôde ser lido" + +#: rayforge/doceditor/reload_cmd.py +msgid "the file contains nothing to import" +msgstr "o arquivo não contém nada para importar" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "Reload {name}" +msgstr "Recarregar {name}" + #: rayforge/doceditor/array_cmd.py msgid "Create Array" msgstr "Criar matriz" @@ -10164,6 +10505,22 @@ msgstr "Criar matriz" msgid "Create array copy" msgstr "Criar cópia da matriz" +#: rayforge/doceditor/path_cleanup_cmd.py +msgid "Close paths" +msgstr "Fechar caminhos" + +#: rayforge/doceditor/path_cleanup_cmd.py +msgid "Join paths" +msgstr "Unir caminhos" + +#: rayforge/doceditor/path_cleanup_cmd.py +msgid "Delete duplicates" +msgstr "Excluir duplicatas" + +#: rayforge/doceditor/path_cleanup_cmd.py +msgid "Break apart" +msgstr "Separar" + #: rayforge/doceditor/editor.py #, python-brace-format msgid "{count} asset(s) require disabled addon '{addon}'" @@ -10241,11 +10598,27 @@ msgstr "Posicionar no Ponto" msgid "Auto Layout" msgstr "Layout Automático" +#: rayforge/image/hpgl/importer.py +#, python-brace-format +msgid "Some HPGL instructions are not supported and were skipped: {names}" +msgstr "Algumas instruções HPGL não são suportadas e foram ignoradas: {names}" + +#: rayforge/image/hpgl/importer.py +#, python-brace-format +msgid "{filename} contains no HPGL drawing commands." +msgstr "{filename} não contém comandos de desenho HPGL." + +#: rayforge/image/hpgl/importer.py +#, python-brace-format +msgid "Pen {number}" +msgstr "Caneta {number}" + #: rayforge/image/png/importer.py msgid "Failed to scan PNG file: {}" msgstr "Falha ao escanear o arquivo PNG: {}" -#: rayforge/image/png/importer.py rayforge/image/jpg/importer.py +#: rayforge/image/png/importer.py rayforge/image/bitmap/importer.py +#: rayforge/image/jpg/importer.py msgid "Failed to process image data." msgstr "Falha ao processar dados de imagem." @@ -10281,6 +10654,15 @@ msgstr "Falha ao rasterizar SVG para rastreamento." msgid "Failed to normalize image data." msgstr "Falha ao normalizar dados de imagem." +#: rayforge/image/svg/importer.py +msgid "This SVGZ file's gzip data could not be decompressed." +msgstr "" + +#: rayforge/image/bitmap/importer.py +#, python-brace-format +msgid "Could not read the {format} image: {error}" +msgstr "Não foi possível ler a imagem {format}: {error}" + #: rayforge/image/pdf/pdf_trace.py rayforge/image/pdf/pdf_vector.py msgid "PDF file contains no pages." msgstr "O ficheiro PDF não contém páginas." @@ -10386,57 +10768,60 @@ msgid "Floyd Steinberg" msgstr "Floyd Steinberg" #: rayforge/image/dither.py -msgid "Bayer 2" -msgstr "Bayer 2" +msgid "Atkinson" +msgstr "Atkinson" #: rayforge/image/dither.py -msgid "Bayer 4" -msgstr "Bayer 4" +msgid "Stucki" +msgstr "Stucki" #: rayforge/image/dither.py -msgid "Bayer 8" -msgstr "Bayer 8" +msgid "Jarvis, Judice & Ninke" +msgstr "Jarvis, Judice & Ninke" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "View Outside Workspace" -msgstr "Vista fora da área de trabalho" +#: rayforge/image/dither.py +msgid "Sierra" +msgstr "Sierra" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Show the camera image in a margin around the work area to help position workpieces. Requires image alignment." -msgstr "Mostra a imagem da câmera em uma margem ao redor da área de trabalho para ajudar a posicionar as peças. Requer o alinhamento da imagem." +#: rayforge/image/dither.py +msgid "Sierra 2-Row" +msgstr "Sierra 2-Row" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Show Outside Workspace" -msgstr "Mostrar fora da área de trabalho" +#: rayforge/image/dither.py +msgid "Sierra Lite" +msgstr "Sierra Lite" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Draw the camera image around the work area" -msgstr "Desenhar a imagem da câmera ao redor da área de trabalho" +#: rayforge/image/dither.py +msgid "Burkes" +msgstr "Burkes" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Not shown until the camera image is aligned" -msgstr "Não é mostrada até que a imagem da câmera esteja alinhada" +#: rayforge/image/dither.py +msgid "Bayer 2" +msgstr "Bayer 2" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Margin" -msgstr "Margem" +#: rayforge/image/dither.py +msgid "Bayer 4" +msgstr "Bayer 4" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Size of the visible area on each side of the work area" -msgstr "Tamanho da área visível em cada lado da área de trabalho" +#: rayforge/image/dither.py +msgid "Bayer 8" +msgstr "Bayer 8" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Transparency outside the work area" -msgstr "Transparência fora da área de trabalho" +#: rayforge/image/dither.py +msgid "Newsprint" +msgstr "Papel jornal" -#: rayforge/image/bitmap/importer.py -#, python-brace-format -msgid "Could not read the {format} image: {error}" -msgstr "Não foi possível ler a imagem {format}: {error}" +#: rayforge/image/dither.py +msgid "Halftone" +msgstr "Meio-tom" #: rayforge/image/postscript/importer.py -msgid "Importing EPS and PostScript files requires Ghostscript, which was not found. Install Ghostscript and try again." -msgstr "Importar arquivos EPS e PostScript requer o Ghostscript, que não foi encontrado. Instale o Ghostscript e tente novamente." +msgid "" +"Importing EPS and PostScript files requires Ghostscript, which was not " +"found. Install Ghostscript and try again." +msgstr "" +"Importar arquivos EPS e PostScript requer o Ghostscript, que não foi " +"encontrado. Instale o Ghostscript e tente novamente." #: rayforge/image/postscript/importer.py #, python-brace-format @@ -10444,47 +10829,12 @@ msgid "Ghostscript could not convert {filename}: {error}" msgstr "O Ghostscript não conseguiu converter {filename}: {error}" #: rayforge/image/postscript/importer.py -msgid "This Illustrator file was saved without PDF compatibility. Rayforge can only read it with Ghostscript, which was not found. Install Ghostscript, or save the file again in Illustrator with \"Create PDF Compatible File\" enabled." -msgstr "Este arquivo do Illustrator foi salvo sem compatibilidade com PDF. O Rayforge só consegue lê-lo com o Ghostscript, que não foi encontrado. Instale o Ghostscript ou salve o arquivo novamente no Illustrator com a opção \"Criar arquivo compatível com PDF\" ativada." - -#: rayforge/image/hpgl/importer.py -#, python-brace-format -msgid "Some HPGL instructions are not supported and were skipped: {names}" -msgstr "Algumas instruções HPGL não são suportadas e foram ignoradas: {names}" - -#: rayforge/image/hpgl/importer.py -#, python-brace-format -msgid "{filename} contains no HPGL drawing commands." -msgstr "{filename} não contém comandos de desenho HPGL." - -#: rayforge/image/hpgl/importer.py -#, python-brace-format -msgid "Pen {number}" -msgstr "Caneta {number}" - -#: rayforge/machine/driver/driver.py -msgid "Home Switch" -msgstr "Interruptor de Referência" - -#: rayforge/machine/driver/driver.py -msgid "Probe" -msgstr "Sonda" - -#: rayforge/ui_gtk/machine/advanced_preferences_page.py -msgid "Z Homing" -msgstr "Referenciação de Z" - -#: rayforge/ui_gtk/machine/advanced_preferences_page.py msgid "" -"How the Z axis finds its reference: a home switch, " -"or the auto-focus probe" -msgstr "Como o eixo Z encontra sua referência: um interruptor de referência ou a sonda de foco automático" - -#: rayforge/ui_gtk/doceditor/frame_popover.py -msgid "Include Overscan Margin" -msgstr "Incluir a margem de overscan" - -#: rayforge/ui_gtk/doceditor/frame_popover.py -msgid "" -"Frame the full machine path, including the extra engraving travel" -msgstr "Moldura o caminho completo da máquina, incluindo o trajeto extra de gravação" +"This Illustrator file was saved without PDF compatibility. Rayforge can only " +"read it with Ghostscript, which was not found. Install Ghostscript, or save " +"the file again in Illustrator with \"Create PDF Compatible File\" enabled." +msgstr "" +"Este arquivo do Illustrator foi salvo sem compatibilidade com PDF. O " +"Rayforge só consegue lê-lo com o Ghostscript, que não foi encontrado. " +"Instale o Ghostscript ou salve o arquivo novamente no Illustrator com a " +"opção \"Criar arquivo compatível com PDF\" ativada." diff --git a/rayforge/locale/rayforge.pot b/rayforge/locale/rayforge.pot index fa1023583..4898ff6b9 100644 --- a/rayforge/locale/rayforge.pot +++ b/rayforge/locale/rayforge.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-10-06 22:41+0200\n" +"POT-Creation-Date: 2026-10-15 02:25+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -436,6 +436,16 @@ msgid "" "the network." msgstr "" +#: rayforge/machine/driver/ruidarpa/rpa_adapter.py +msgid "TCP protocol" +msgstr "" + +#: rayforge/machine/driver/ruidarpa/rpa_adapter.py +msgid "" +"Use TCP instead of UDP for the network connection. Needed by controllers " +"that do not respond over UDP." +msgstr "" + #: rayforge/machine/driver/ruidarpa/rpa_adapter.py #: rayforge/machine/driver/octoprint/octoprint_driver.py #: rayforge/machine/driver/grbl/grbl_telnet.py @@ -588,6 +598,14 @@ msgid "" "reliably. Use it at your own risk." msgstr "" +#: rayforge/machine/driver/driver.py +msgid "Home Switch" +msgstr "" + +#: rayforge/machine/driver/driver.py +msgid "Probe" +msgstr "" + #: rayforge/machine/driver/driver.py rayforge/machine/transport/transport.py #: rayforge/ui_gtk/machine/machine_dropdown.py #: rayforge/ui_gtk/machine/status_widget.py @@ -2453,6 +2471,7 @@ msgstr "" #: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py #: rayforge/ui_gtk/doceditor/material_library_list.py #: rayforge/ui_gtk/doceditor/add_material_dialog.py +#: rayforge/ui_gtk/doceditor/path_tolerance_dialog.py #: rayforge/ui_gtk/doceditor/step_type_selection_dialog.py #: rayforge/ui_gtk/doceditor/material_selector.py #: rayforge/ui_gtk/doceditor/material_list.py @@ -2955,6 +2974,7 @@ msgid "Length" msgstr "" #: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/machine/bidir_offset_table.py #: rayforge/ui_gtk/doceditor/bottom_panel.py msgid "Speed" msgstr "" @@ -2989,6 +3009,14 @@ msgstr "" msgid "Automatically check for new Rayforge versions on startup" msgstr "" +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Watch imported files" +msgstr "" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Offer to reload imported files when they change on disk" +msgstr "" + #: rayforge/ui_gtk/settings/general_preferences_page.py msgid "Startup behavior" msgstr "" @@ -3162,10 +3190,42 @@ msgstr "" msgid "Ungroup" msgstr "" +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +msgid "Break Apart" +msgstr "" + +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +msgid "Close Paths…" +msgstr "" + +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +msgid "Join Open Paths…" +msgstr "" + +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +msgid "Delete Duplicates" +msgstr "" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +msgid "Clean Up Paths" +msgstr "" + #: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/doceditor/stock_cmd.py msgid "Convert to Stock" msgstr "" +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +#: rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Reload from Disk" +msgstr "" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +#: rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Relink Source File…" +msgstr "" + #: rayforge/ui_gtk/canvas2d/context_menu.py msgid "Add Tab Here" msgstr "" @@ -3232,6 +3292,59 @@ msgstr "" msgid "Auto Layout (Simple)" msgstr "" +#: rayforge/ui_gtk/actions.py +#, python-brace-format +msgid "The file of {name} cannot be found. Use Relink to pick it." +msgstr "" + +#: rayforge/ui_gtk/actions.py +msgid "Nothing to break apart" +msgstr "" + +#: rayforge/ui_gtk/actions.py +#, python-brace-format +msgid "Closed {count} path" +msgid_plural "Closed {count} paths" +msgstr[0] "" +msgstr[1] "" + +#: rayforge/ui_gtk/actions.py +msgid "Close Paths" +msgstr "" + +#: rayforge/ui_gtk/actions.py +msgid "" +"Closes open paths whose start and end point are closer than the tolerance." +msgstr "" + +#: rayforge/ui_gtk/actions.py +#, python-brace-format +msgid "Made {count} join" +msgid_plural "Made {count} joins" +msgstr[0] "" +msgstr[1] "" + +#: rayforge/ui_gtk/actions.py +msgid "Join Open Paths" +msgstr "" + +#: rayforge/ui_gtk/actions.py +msgid "" +"Joins open paths whose end points are closer than the tolerance. Paths whose " +"ends then meet are closed." +msgstr "" + +#: rayforge/ui_gtk/actions.py +msgid "Join Paths" +msgstr "" + +#: rayforge/ui_gtk/actions.py +#, python-brace-format +msgid "Deleted {count} duplicate" +msgid_plural "Deleted {count} duplicates" +msgstr[0] "" +msgstr[1] "" + #: rayforge/ui_gtk/camera/lens_calibration_dialog.py #, python-brace-format msgid "{camera_name} - Lens Calibration" @@ -3322,6 +3435,7 @@ msgid "Temporal averaging, higher values cause trailing" msgstr "" #: rayforge/ui_gtk/camera/image_settings_widget.py +#: rayforge/ui_gtk/camera/outside_view_widget.py msgid "Transparency" msgstr "" @@ -4031,6 +4145,40 @@ msgstr "" msgid "Stream a camera image directly onto the work surface." msgstr "" +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "View Outside Workspace" +msgstr "" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "" +"Show the camera image in a margin around the work area to help position " +"workpieces. Requires image alignment." +msgstr "" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Show Outside Workspace" +msgstr "" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Draw the camera image around the work area" +msgstr "" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Margin" +msgstr "" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Size of the visible area on each side of the work area" +msgstr "" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Transparency outside the work area" +msgstr "" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Not shown until the camera image is aligned" +msgstr "" + #: rayforge/ui_gtk/camera/alignment_widget.py msgid "" "Click the image to add reference points. Drag to move them.\n" @@ -5051,6 +5199,15 @@ msgstr "" msgid "Enable individual axis homing controls in the jog dialog" msgstr "" +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Z Homing" +msgstr "" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "" +"How the Z axis finds its reference: a home switch, or the auto-focus probe" +msgstr "" + #: rayforge/ui_gtk/machine/advanced_preferences_page.py msgid "Clear Alarm On Connect" msgstr "" @@ -5486,6 +5643,42 @@ msgid "" "profile updates." msgstr "" +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "Offset" +msgstr "" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "Positive values shift right-to-left passes to the right" +msgstr "" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "Remove Row" +msgstr "" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +#, python-brace-format +msgid "{speed}: {offset}" +msgstr "" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "Bidirectional Scan Offset by Speed" +msgstr "" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "" +"Shift of right-to-left raster passes at each engrave speed, interpolated " +"between rows. Engrave steps use it when their own Bidirectional Scan Offset " +"is 0." +msgstr "" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "Add Row" +msgstr "" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "No rows: all speeds use an offset of 0" +msgstr "" + #: rayforge/ui_gtk/machine/wizard_pages/camera_page.py msgid "" "Optional. Configure any cameras you want to use for preview and alignment." @@ -7200,6 +7393,10 @@ msgstr "" msgid "Import..." msgstr "" +#: rayforge/ui_gtk/main_menu.py +msgid "Relink Source File..." +msgstr "" + #: rayforge/ui_gtk/main_menu.py msgid "Export G-code..." msgstr "" @@ -7395,7 +7592,7 @@ msgstr "" msgid "_Tools" msgstr "" -#: rayforge/ui_gtk/main_menu.py +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/doceditor/frame_popover.py msgid "Frame" msgstr "" @@ -8152,10 +8349,23 @@ msgstr "" msgid "Failed to create library. A folder with that name may already exist." msgstr "" +#: rayforge/ui_gtk/doceditor/frame_popover.py +msgid "Include Overscan Margin" +msgstr "" + +#: rayforge/ui_gtk/doceditor/frame_popover.py +msgid "Frame the full machine path, including the extra engraving travel" +msgstr "" + #: rayforge/ui_gtk/doceditor/file_dialogs.py msgid "Open File" msgstr "" +#: rayforge/ui_gtk/doceditor/file_dialogs.py +#, python-brace-format +msgid "Relink “{name}”" +msgstr "" + #: rayforge/ui_gtk/doceditor/file_dialogs.py msgid "All supported" msgstr "" @@ -8276,6 +8486,10 @@ msgstr "" msgid "Add New Layer" msgstr "" +#: rayforge/ui_gtk/doceditor/path_tolerance_dialog.py +msgid "Tolerance" +msgstr "" + #: rayforge/ui_gtk/doceditor/stock_properties_dialog.py #: rayforge/ui_gtk/doceditor/item_properties.py msgid "Stock Properties" @@ -8411,6 +8625,39 @@ msgstr "" msgid "Set current position as 0" msgstr "" +#: rayforge/ui_gtk/doceditor/source_changed_dialog.py +msgid "Imported File Changed" +msgid_plural "Imported Files Changed" +msgstr[0] "" +msgstr[1] "" + +#: rayforge/ui_gtk/doceditor/source_changed_dialog.py +#, python-brace-format +msgid "" +"\"{name}\" was changed on disk. Reload it? Position, size and settings are " +"kept." +msgstr "" + +#: rayforge/ui_gtk/doceditor/source_changed_dialog.py +msgid "" +"These imported files were changed on disk. Reload them? Position, size and " +"settings are kept." +msgstr "" + +#: rayforge/ui_gtk/doceditor/source_changed_dialog.py +msgid "_Ignore" +msgstr "" + +#: rayforge/ui_gtk/doceditor/source_changed_dialog.py +msgid "_Reload" +msgstr "" + +#: rayforge/ui_gtk/doceditor/source_changed_dialog.py +msgid "Always reload this file without asking" +msgid_plural "Always reload the selected files without asking" +msgstr[0] "" +msgstr[1] "" + #: rayforge/ui_gtk/doceditor/step_type_selection_dialog.py msgid "Select Step Types" msgstr "" @@ -9589,6 +9836,73 @@ msgstr "" msgid "Reorder items" msgstr "" +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "{name} ({x:.1f}, {y:.1f} mm)" +msgstr "" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "Could not reload {name}: {error}" +msgstr "" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "Reloaded {name}" +msgstr "" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "{n} new element added" +msgid_plural "{n} new elements added" +msgstr[0] "" +msgstr[1] "" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "{n} element no longer in the file was removed" +msgid_plural "{n} elements no longer in the file were removed" +msgstr[0] "" +msgstr[1] "" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "{n} edited element was left unchanged" +msgid_plural "{n} edited elements were left unchanged" +msgstr[0] "" +msgstr[1] "" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "and {n} more" +msgstr "" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "" +"{n} tab more than {limit:.1f} mm from the new outline was removed: {places}" +msgid_plural "" +"{n} tabs more than {limit:.1f} mm from the new outline were removed: {places}" +msgstr[0] "" +msgstr[1] "" + +#: rayforge/doceditor/reload_cmd.py +msgid "the file type is not known" +msgstr "" + +#: rayforge/doceditor/reload_cmd.py +msgid "the file could not be read" +msgstr "" + +#: rayforge/doceditor/reload_cmd.py +msgid "the file contains nothing to import" +msgstr "" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "Reload {name}" +msgstr "" + #: rayforge/doceditor/array_cmd.py msgid "Create Array" msgstr "" @@ -9597,6 +9911,22 @@ msgstr "" msgid "Create array copy" msgstr "" +#: rayforge/doceditor/path_cleanup_cmd.py +msgid "Close paths" +msgstr "" + +#: rayforge/doceditor/path_cleanup_cmd.py +msgid "Join paths" +msgstr "" + +#: rayforge/doceditor/path_cleanup_cmd.py +msgid "Delete duplicates" +msgstr "" + +#: rayforge/doceditor/path_cleanup_cmd.py +msgid "Break apart" +msgstr "" + #: rayforge/doceditor/editor.py #, python-brace-format msgid "{count} asset(s) require disabled addon '{addon}'" @@ -9674,11 +10004,27 @@ msgstr "" msgid "Auto Layout" msgstr "" +#: rayforge/image/hpgl/importer.py +#, python-brace-format +msgid "Some HPGL instructions are not supported and were skipped: {names}" +msgstr "" + +#: rayforge/image/hpgl/importer.py +#, python-brace-format +msgid "{filename} contains no HPGL drawing commands." +msgstr "" + +#: rayforge/image/hpgl/importer.py +#, python-brace-format +msgid "Pen {number}" +msgstr "" + #: rayforge/image/png/importer.py msgid "Failed to scan PNG file: {}" msgstr "" -#: rayforge/image/png/importer.py rayforge/image/jpg/importer.py +#: rayforge/image/png/importer.py rayforge/image/bitmap/importer.py +#: rayforge/image/jpg/importer.py msgid "Failed to process image data." msgstr "" @@ -9714,6 +10060,15 @@ msgstr "" msgid "Failed to normalize image data." msgstr "" +#: rayforge/image/svg/importer.py +msgid "This SVGZ file's gzip data could not be decompressed." +msgstr "" + +#: rayforge/image/bitmap/importer.py +#, python-brace-format +msgid "Could not read the {format} image: {error}" +msgstr "" + #: rayforge/image/pdf/pdf_trace.py rayforge/image/pdf/pdf_vector.py msgid "PDF file contains no pages." msgstr "" @@ -9819,56 +10174,57 @@ msgid "Floyd Steinberg" msgstr "" #: rayforge/image/dither.py -msgid "Bayer 2" +msgid "Atkinson" msgstr "" #: rayforge/image/dither.py -msgid "Bayer 4" +msgid "Stucki" msgstr "" #: rayforge/image/dither.py -msgid "Bayer 8" +msgid "Jarvis, Judice & Ninke" msgstr "" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "View Outside Workspace" +#: rayforge/image/dither.py +msgid "Sierra" msgstr "" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Show the camera image in a margin around the work area to help position workpieces. Requires image alignment." +#: rayforge/image/dither.py +msgid "Sierra 2-Row" msgstr "" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Show Outside Workspace" +#: rayforge/image/dither.py +msgid "Sierra Lite" msgstr "" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Draw the camera image around the work area" +#: rayforge/image/dither.py +msgid "Burkes" msgstr "" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Not shown until the camera image is aligned" +#: rayforge/image/dither.py +msgid "Bayer 2" msgstr "" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Margin" +#: rayforge/image/dither.py +msgid "Bayer 4" msgstr "" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Size of the visible area on each side of the work area" +#: rayforge/image/dither.py +msgid "Bayer 8" msgstr "" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Transparency outside the work area" +#: rayforge/image/dither.py +msgid "Newsprint" msgstr "" -#: rayforge/image/bitmap/importer.py -#, python-brace-format -msgid "Could not read the {format} image: {error}" +#: rayforge/image/dither.py +msgid "Halftone" msgstr "" #: rayforge/image/postscript/importer.py -msgid "Importing EPS and PostScript files requires Ghostscript, which was not found. Install Ghostscript and try again." +msgid "" +"Importing EPS and PostScript files requires Ghostscript, which was not " +"found. Install Ghostscript and try again." msgstr "" #: rayforge/image/postscript/importer.py @@ -9877,47 +10233,8 @@ msgid "Ghostscript could not convert {filename}: {error}" msgstr "" #: rayforge/image/postscript/importer.py -msgid "This Illustrator file was saved without PDF compatibility. Rayforge can only read it with Ghostscript, which was not found. Install Ghostscript, or save the file again in Illustrator with \"Create PDF Compatible File\" enabled." -msgstr "" - -#: rayforge/image/hpgl/importer.py -#, python-brace-format -msgid "Some HPGL instructions are not supported and were skipped: {names}" -msgstr "" - -#: rayforge/image/hpgl/importer.py -#, python-brace-format -msgid "{filename} contains no HPGL drawing commands." -msgstr "" - -#: rayforge/image/hpgl/importer.py -#, python-brace-format -msgid "Pen {number}" -msgstr "" - -#: rayforge/machine/driver/driver.py -msgid "Home Switch" -msgstr "" - -#: rayforge/machine/driver/driver.py -msgid "Probe" -msgstr "" - -#: rayforge/ui_gtk/machine/advanced_preferences_page.py -msgid "Z Homing" -msgstr "" - -#: rayforge/ui_gtk/machine/advanced_preferences_page.py -msgid "" -"How the Z axis finds its reference: a home switch, " -"or the auto-focus probe" -msgstr "" - -#: rayforge/ui_gtk/doceditor/frame_popover.py -msgid "Include Overscan Margin" -msgstr "" - -#: rayforge/ui_gtk/doceditor/frame_popover.py msgid "" -"Frame the full machine path, including the extra engraving travel" +"This Illustrator file was saved without PDF compatibility. Rayforge can only " +"read it with Ghostscript, which was not found. Install Ghostscript, or save " +"the file again in Illustrator with \"Create PDF Compatible File\" enabled." msgstr "" diff --git a/rayforge/locale/uk/LC_MESSAGES/rayforge.po b/rayforge/locale/uk/LC_MESSAGES/rayforge.po index 755be5277..a73b533b7 100644 --- a/rayforge/locale/uk/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/uk/LC_MESSAGES/rayforge.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: Rayforge\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-10-06 22:41+0200\n" +"POT-Creation-Date: 2026-10-15 02:25+0200\n" "PO-Revision-Date: 2026-02-23 01:17+0100\n" "Last-Translator: Samuel Abels \n" "Language-Team: Ukrainian\n" @@ -439,6 +439,18 @@ msgstr "" "Який транспорт використовувати. Автоматично відкриває USB, якщо він " "доступний, і переходить до мережі в іншому разі." +#: rayforge/machine/driver/ruidarpa/rpa_adapter.py +msgid "TCP protocol" +msgstr "Протокол TCP" + +#: rayforge/machine/driver/ruidarpa/rpa_adapter.py +msgid "" +"Use TCP instead of UDP for the network connection. Needed by controllers " +"that do not respond over UDP." +msgstr "" +"Використовувати TCP замість UDP для мережевого з'єднання. Потрібно для " +"контролерів, які не відповідають через UDP." + #: rayforge/machine/driver/ruidarpa/rpa_adapter.py #: rayforge/machine/driver/octoprint/octoprint_driver.py #: rayforge/machine/driver/grbl/grbl_telnet.py @@ -622,6 +634,14 @@ msgstr "" "Цей драйвер експериментальний і майже напевно містить помилки. Він може " "працювати нестабільно. Використовуйте на власний ризик." +#: rayforge/machine/driver/driver.py +msgid "Home Switch" +msgstr "Кінцевий вимикач" + +#: rayforge/machine/driver/driver.py +msgid "Probe" +msgstr "Зонд" + #: rayforge/machine/driver/driver.py rayforge/machine/transport/transport.py #: rayforge/ui_gtk/machine/machine_dropdown.py #: rayforge/ui_gtk/machine/status_widget.py @@ -2650,6 +2670,7 @@ msgstr "" #: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py #: rayforge/ui_gtk/doceditor/material_library_list.py #: rayforge/ui_gtk/doceditor/add_material_dialog.py +#: rayforge/ui_gtk/doceditor/path_tolerance_dialog.py #: rayforge/ui_gtk/doceditor/step_type_selection_dialog.py #: rayforge/ui_gtk/doceditor/material_selector.py #: rayforge/ui_gtk/doceditor/material_list.py @@ -2880,16 +2901,16 @@ msgstr "Встановлення, оновлення та видалення д #: rayforge/ui_gtk/settings/gesture_preferences_page.py msgid "Mouse Gestures" -msgstr "" +msgstr "Жести мишею" #: rayforge/ui_gtk/settings/gesture_preferences_page.py #, python-format msgid "Default: %s" -msgstr "" +msgstr "Типово: %s" #: rayforge/ui_gtk/settings/gesture_preferences_page.py msgid "Unassigned" -msgstr "" +msgstr "Не призначено" #: rayforge/ui_gtk/settings/recipe_manager_page.py msgid "Recipes" @@ -3165,6 +3186,7 @@ msgid "Length" msgstr "Довжина" #: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/machine/bidir_offset_table.py #: rayforge/ui_gtk/doceditor/bottom_panel.py msgid "Speed" msgstr "Швидкість" @@ -3202,6 +3224,16 @@ msgstr "Перевірити оновлення" msgid "Automatically check for new Rayforge versions on startup" msgstr "Автоматично перевіряти наявність нових версій Rayforge при запуску" +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Watch imported files" +msgstr "Відстежувати імпортовані файли" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Offer to reload imported files when they change on disk" +msgstr "" +"Пропонувати перезавантажувати імпортовані файли, коли вони змінюються на " +"диску" + #: rayforge/ui_gtk/settings/general_preferences_page.py msgid "Startup behavior" msgstr "Поведінка при запуску" @@ -3380,10 +3412,42 @@ msgstr "Згрупувати" msgid "Ungroup" msgstr "Розгрупувати" +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +msgid "Break Apart" +msgstr "Роз'єднати" + +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +msgid "Close Paths…" +msgstr "Закрити шляхи…" + +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +msgid "Join Open Paths…" +msgstr "З'єднати відкриті шляхи…" + +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +msgid "Delete Duplicates" +msgstr "Видалити дублікати" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +msgid "Clean Up Paths" +msgstr "Очистити шляхи" + #: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/doceditor/stock_cmd.py msgid "Convert to Stock" msgstr "Перетворити в матеріал" +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +#: rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Reload from Disk" +msgstr "Перезавантажити з диска" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +#: rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Relink Source File…" +msgstr "Прив'язати вихідний файл знову…" + #: rayforge/ui_gtk/canvas2d/context_menu.py msgid "Add Tab Here" msgstr "Додати вкладку тут" @@ -3450,6 +3514,66 @@ msgstr "Додати матеріал" msgid "Auto Layout (Simple)" msgstr "Автоматичне компонування (Просте)" +#: rayforge/ui_gtk/actions.py +#, python-brace-format +msgid "The file of {name} cannot be found. Use Relink to pick it." +msgstr "" +"Файл {name} не знайдено. Скористайтеся «Прив'язати знову», щоб вибрати його." + +#: rayforge/ui_gtk/actions.py +msgid "Nothing to break apart" +msgstr "Немає що роз'єднувати" + +#: rayforge/ui_gtk/actions.py +#, python-brace-format +msgid "Closed {count} path" +msgid_plural "Closed {count} paths" +msgstr[0] "{count} шлях закрито" +msgstr[1] "{count} шляхи закрито" +msgstr[2] "{count} шляхів закрито" + +#: rayforge/ui_gtk/actions.py +msgid "Close Paths" +msgstr "Закрити шляхи" + +#: rayforge/ui_gtk/actions.py +msgid "" +"Closes open paths whose start and end point are closer than the tolerance." +msgstr "" +"Закриває відкриті шляхи, початкова й кінцева точки яких ближчі за допуск." + +#: rayforge/ui_gtk/actions.py +#, python-brace-format +msgid "Made {count} join" +msgid_plural "Made {count} joins" +msgstr[0] "{count} з'єднання зроблено" +msgstr[1] "{count} з'єднання зроблено" +msgstr[2] "{count} з'єднань зроблено" + +#: rayforge/ui_gtk/actions.py +msgid "Join Open Paths" +msgstr "З'єднати відкриті шляхи" + +#: rayforge/ui_gtk/actions.py +msgid "" +"Joins open paths whose end points are closer than the tolerance. Paths whose " +"ends then meet are closed." +msgstr "" +"З'єднує відкриті шляхи, кінцеві точки яких ближчі за допуск. Шляхи, кінці " +"яких потім збігаються, закриваються." + +#: rayforge/ui_gtk/actions.py +msgid "Join Paths" +msgstr "З'єднати шляхи" + +#: rayforge/ui_gtk/actions.py +#, python-brace-format +msgid "Deleted {count} duplicate" +msgid_plural "Deleted {count} duplicates" +msgstr[0] "{count} дублікат видалено" +msgstr[1] "{count} дублікати видалено" +msgstr[2] "{count} дублікатів видалено" + #: rayforge/ui_gtk/camera/lens_calibration_dialog.py #, python-brace-format msgid "{camera_name} - Lens Calibration" @@ -3545,6 +3669,7 @@ msgid "Temporal averaging, higher values cause trailing" msgstr "Часове усереднення, вищі значення викликають розмиття" #: rayforge/ui_gtk/camera/image_settings_widget.py +#: rayforge/ui_gtk/camera/outside_view_widget.py msgid "Transparency" msgstr "Прозорість" @@ -4282,6 +4407,42 @@ msgstr "Камери" msgid "Stream a camera image directly onto the work surface." msgstr "Транслювати зображення камери безпосередньо на робочу поверхню." +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "View Outside Workspace" +msgstr "Вигляд поза робочою областю" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "" +"Show the camera image in a margin around the work area to help position " +"workpieces. Requires image alignment." +msgstr "" +"Показувати зображення камери в полі навколо робочої області, щоб допомогти " +"розташувати заготовки. Потребує вирівнювання зображення." + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Show Outside Workspace" +msgstr "Показувати поза робочою областю" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Draw the camera image around the work area" +msgstr "Малювати зображення камери навколо робочої області" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Margin" +msgstr "Поле" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Size of the visible area on each side of the work area" +msgstr "Розмір видимої області з кожного боку робочої області" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Transparency outside the work area" +msgstr "Прозорість поза робочою областю" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Not shown until the camera image is aligned" +msgstr "Не показується, доки зображення камери не вирівняно" + #: rayforge/ui_gtk/camera/alignment_widget.py msgid "" "Click the image to add reference points. Drag to move them.\n" @@ -5376,6 +5537,17 @@ msgid "Enable individual axis homing controls in the jog dialog" msgstr "" "Увімкнути елементи керування паркуванням окремих осей у діалозі переміщення" +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Z Homing" +msgstr "Паркування Z" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "" +"How the Z axis finds its reference: a home switch, or the auto-focus probe" +msgstr "" +"Як вісь Z знаходить свою точку відліку: за кінцевим вимикачем або зондом " +"автофокусу" + #: rayforge/ui_gtk/machine/advanced_preferences_page.py msgid "Clear Alarm On Connect" msgstr "Очистити тривогу при підключенні" @@ -5824,6 +5996,45 @@ msgstr "" "Ці нотатки зберігаються разом із цією машиною й ніколи не перезаписуються " "оновленнями профілю пристрою." +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "Offset" +msgstr "Зсув" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "Positive values shift right-to-left passes to the right" +msgstr "Додатні значення зсувають проходи справа наліво вправо" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "Remove Row" +msgstr "Видалити рядок" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +#, python-brace-format +msgid "{speed}: {offset}" +msgstr "{speed}: {offset}" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "Bidirectional Scan Offset by Speed" +msgstr "Зсув двонаправленого сканування за швидкістю" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "" +"Shift of right-to-left raster passes at each engrave speed, interpolated " +"between rows. Engrave steps use it when their own Bidirectional Scan Offset " +"is 0." +msgstr "" +"Зсув проходів растру справа наліво на кожній швидкості гравіювання, " +"інтерпольований між рядками. Кроки гравіювання використовують його, коли " +"їхній власний зсув двонаправленого сканування дорівнює 0." + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "Add Row" +msgstr "Додати рядок" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "No rows: all speeds use an offset of 0" +msgstr "Немає рядків: усі швидкості використовують зсув 0" + #: rayforge/ui_gtk/machine/wizard_pages/camera_page.py msgid "" "Optional. Configure any cameras you want to use for preview and alignment." @@ -7645,6 +7856,10 @@ msgstr "Нещодавні файли" msgid "Import..." msgstr "Імпортувати..." +#: rayforge/ui_gtk/main_menu.py +msgid "Relink Source File..." +msgstr "Прив'язати вихідний файл знову..." + #: rayforge/ui_gtk/main_menu.py msgid "Export G-code..." msgstr "Експортувати G-код..." @@ -7840,7 +8055,7 @@ msgstr "Впорядкувати" msgid "_Tools" msgstr "_Інструменти" -#: rayforge/ui_gtk/main_menu.py +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/doceditor/frame_popover.py msgid "Frame" msgstr "Рамка" @@ -8081,79 +8296,80 @@ msgstr "Сталася неочікувана помилка: {error}" #: rayforge/ui_gtk/gestures/model.py msgid "Click" -msgstr "" +msgstr "Клацання" #: rayforge/ui_gtk/gestures/model.py msgid "Double-click" -msgstr "" +msgstr "Подвійне клацання" #: rayforge/ui_gtk/gestures/model.py msgid "Drag" -msgstr "" +msgstr "Перетягування" #: rayforge/ui_gtk/gestures/model.py msgid "Scroll" -msgstr "" +msgstr "Прокручування" #: rayforge/ui_gtk/gestures/model.py msgid "Middle" -msgstr "" +msgstr "Середня" #: rayforge/ui_gtk/gestures/model.py msgid "Shift" -msgstr "" +msgstr "Shift" #: rayforge/ui_gtk/gestures/model.py msgid "Ctrl" -msgstr "" +msgstr "Ctrl" #: rayforge/ui_gtk/gestures/model.py msgid "Alt" -msgstr "" +msgstr "Alt" #: rayforge/ui_gtk/gestures/builtin.py msgid "2D Canvas" -msgstr "" +msgstr "Полотно 2D" #: rayforge/ui_gtk/gestures/builtin.py msgid "Navigation gestures on the 2D work surface." -msgstr "" +msgstr "Жести навігації на робочій поверхні 2D." #: rayforge/ui_gtk/gestures/builtin.py msgid "3D Canvas" -msgstr "" +msgstr "Полотно 3D" #: rayforge/ui_gtk/gestures/builtin.py msgid "Navigation gestures on the 3D preview." -msgstr "" +msgstr "Жести навігації в перегляді 3D." #: rayforge/ui_gtk/gestures/builtin.py msgid "Pan the view" -msgstr "" +msgstr "Панорамувати вигляд" #: rayforge/ui_gtk/gestures/builtin.py msgid "Hold the mouse button down and move to pan the view." -msgstr "" +msgstr "Утримуйте кнопку миші натиснутою та рухайте, щоб панорамувати вигляд." #: rayforge/ui_gtk/gestures/builtin.py msgid "Orbit the camera" -msgstr "" +msgstr "Обертати камеру навколо" #: rayforge/ui_gtk/gestures/builtin.py msgid "Hold the mouse button down and move to orbit around the scene." msgstr "" +"Утримуйте кнопку миші натиснутою та рухайте, щоб обертати навколо сцени." #: rayforge/ui_gtk/gestures/builtin.py msgid "Pan the camera" -msgstr "" +msgstr "Панорамувати камеру" #: rayforge/ui_gtk/gestures/builtin.py msgid "Hold the mouse button down and move to pan the scene." -msgstr "" +msgstr "Утримуйте кнопку миші натиснутою та рухайте, щоб панорамувати сцену." #: rayforge/ui_gtk/gestures/builtin.py msgid "Rotate around the Z axis" -msgstr "" +msgstr "Обертати навколо осі Z" #: rayforge/ui_gtk/project_cmd.py msgid "Unsaved Changes" @@ -8624,10 +8840,23 @@ msgid "Failed to create library. A folder with that name may already exist." msgstr "" "Не вдалося створити бібліотеку. Папка з такою назвою може вже існувати." +#: rayforge/ui_gtk/doceditor/frame_popover.py +msgid "Include Overscan Margin" +msgstr "Урахувати межі overscan" + +#: rayforge/ui_gtk/doceditor/frame_popover.py +msgid "Frame the full machine path, including the extra engraving travel" +msgstr "Окреслює повний шлях верстата, включно з додатковим ходом гравіювання" + #: rayforge/ui_gtk/doceditor/file_dialogs.py msgid "Open File" msgstr "Відкрити файл" +#: rayforge/ui_gtk/doceditor/file_dialogs.py +#, python-brace-format +msgid "Relink “{name}”" +msgstr "Прив'язати знову “{name}”" + #: rayforge/ui_gtk/doceditor/file_dialogs.py msgid "All supported" msgstr "Усі підтримувані" @@ -8748,6 +8977,10 @@ msgstr "Власний" msgid "Add New Layer" msgstr "Додати новий шар" +#: rayforge/ui_gtk/doceditor/path_tolerance_dialog.py +msgid "Tolerance" +msgstr "Допуск" + #: rayforge/ui_gtk/doceditor/stock_properties_dialog.py #: rayforge/ui_gtk/doceditor/item_properties.py msgid "Stock Properties" @@ -8885,6 +9118,45 @@ msgstr "Верстат має бути підключено для встано msgid "Set current position as 0" msgstr "Встановити поточну позицію як 0" +#: rayforge/ui_gtk/doceditor/source_changed_dialog.py +msgid "Imported File Changed" +msgid_plural "Imported Files Changed" +msgstr[0] "Імпортований файл змінено" +msgstr[1] "Імпортовані файли змінено" +msgstr[2] "Імпортованих файлів змінено" + +#: rayforge/ui_gtk/doceditor/source_changed_dialog.py +#, python-brace-format +msgid "" +"\"{name}\" was changed on disk. Reload it? Position, size and settings are " +"kept." +msgstr "" +"«{name}» змінено на диску. Перезавантажити? Позиція, розмір і налаштування " +"збережуться." + +#: rayforge/ui_gtk/doceditor/source_changed_dialog.py +msgid "" +"These imported files were changed on disk. Reload them? Position, size and " +"settings are kept." +msgstr "" +"Ці імпортовані файли змінено на диску. Перезавантажити їх? Позиція, розмір і " +"налаштування збережуться." + +#: rayforge/ui_gtk/doceditor/source_changed_dialog.py +msgid "_Ignore" +msgstr "_Ігнорувати" + +#: rayforge/ui_gtk/doceditor/source_changed_dialog.py +msgid "_Reload" +msgstr "_Перезавантажити" + +#: rayforge/ui_gtk/doceditor/source_changed_dialog.py +msgid "Always reload this file without asking" +msgid_plural "Always reload the selected files without asking" +msgstr[0] "Завжди перезавантажувати цей файл без запитання" +msgstr[1] "Завжди перезавантажувати вибрані файли без запитання" +msgstr[2] "Завжди перезавантажувати вибрані файли без запитання" + #: rayforge/ui_gtk/doceditor/step_type_selection_dialog.py msgid "Select Step Types" msgstr "Вибрати типи кроків" @@ -10116,6 +10388,83 @@ msgstr "Перевпорядкувати деталі" msgid "Reorder items" msgstr "Змінити порядок елементів" +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "{name} ({x:.1f}, {y:.1f} mm)" +msgstr "{name} ({x:.1f}, {y:.1f} mm)" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "Could not reload {name}: {error}" +msgstr "Не вдалося перезавантажити {name}: {error}" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "Reloaded {name}" +msgstr "{name} перезавантажено" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "{n} new element added" +msgid_plural "{n} new elements added" +msgstr[0] "{n} новий елемент додано" +msgstr[1] "{n} нові елементи додано" +msgstr[2] "{n} нових елементів додано" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "{n} element no longer in the file was removed" +msgid_plural "{n} elements no longer in the file were removed" +msgstr[0] "{n} елемент, якого більше немає у файлі, видалено" +msgstr[1] "{n} елементи, яких більше немає у файлі, видалено" +msgstr[2] "{n} елементів, яких більше немає у файлі, видалено" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "{n} edited element was left unchanged" +msgid_plural "{n} edited elements were left unchanged" +msgstr[0] "{n} змінений елемент залишено без змін" +msgstr[1] "{n} змінені елементи залишено без змін" +msgstr[2] "{n} змінених елементів залишено без змін" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "and {n} more" +msgstr "та ще {n}" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "" +"{n} tab more than {limit:.1f} mm from the new outline was removed: {places}" +msgid_plural "" +"{n} tabs more than {limit:.1f} mm from the new outline were removed: {places}" +msgstr[0] "" +"{n} табуляцію, розташовану далі ніж {limit:.1f} мм від нового контуру, " +"видалено: {places}" +msgstr[1] "" +"{n} табуляції, розташовані далі ніж {limit:.1f} мм від нового контуру, " +"видалено: {places}" +msgstr[2] "" +"{n} табуляцій, розташованих далі ніж {limit:.1f} мм від нового контуру, " +"видалено: {places}" + +#: rayforge/doceditor/reload_cmd.py +msgid "the file type is not known" +msgstr "тип файлу невідомий" + +#: rayforge/doceditor/reload_cmd.py +msgid "the file could not be read" +msgstr "не вдалося прочитати файл" + +#: rayforge/doceditor/reload_cmd.py +msgid "the file contains nothing to import" +msgstr "файл не містить нічого для імпорту" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "Reload {name}" +msgstr "Перезавантажити {name}" + #: rayforge/doceditor/array_cmd.py msgid "Create Array" msgstr "Створити масив" @@ -10124,6 +10473,22 @@ msgstr "Створити масив" msgid "Create array copy" msgstr "Створити копію масиву" +#: rayforge/doceditor/path_cleanup_cmd.py +msgid "Close paths" +msgstr "Закрити шляхи" + +#: rayforge/doceditor/path_cleanup_cmd.py +msgid "Join paths" +msgstr "З'єднати шляхи" + +#: rayforge/doceditor/path_cleanup_cmd.py +msgid "Delete duplicates" +msgstr "Видалити дублікати" + +#: rayforge/doceditor/path_cleanup_cmd.py +msgid "Break apart" +msgstr "Роз'єднати" + #: rayforge/doceditor/editor.py #, python-brace-format msgid "{count} asset(s) require disabled addon '{addon}'" @@ -10201,11 +10566,27 @@ msgstr "Позиціювати в точці" msgid "Auto Layout" msgstr "Автоматичне компонування" +#: rayforge/image/hpgl/importer.py +#, python-brace-format +msgid "Some HPGL instructions are not supported and were skipped: {names}" +msgstr "Деякі інструкції HPGL не підтримуються і були пропущені: {names}" + +#: rayforge/image/hpgl/importer.py +#, python-brace-format +msgid "{filename} contains no HPGL drawing commands." +msgstr "{filename} не містить команд малювання HPGL." + +#: rayforge/image/hpgl/importer.py +#, python-brace-format +msgid "Pen {number}" +msgstr "Перо {number}" + #: rayforge/image/png/importer.py msgid "Failed to scan PNG file: {}" msgstr "Не вдалося сканувати файл PNG: {}" -#: rayforge/image/png/importer.py rayforge/image/jpg/importer.py +#: rayforge/image/png/importer.py rayforge/image/bitmap/importer.py +#: rayforge/image/jpg/importer.py msgid "Failed to process image data." msgstr "Не вдалося обробити дані зображення." @@ -10241,6 +10622,15 @@ msgstr "Не вдалося растеризувати SVG для трасува msgid "Failed to normalize image data." msgstr "Не вдалося нормалізувати дані зображення." +#: rayforge/image/svg/importer.py +msgid "This SVGZ file's gzip data could not be decompressed." +msgstr "" + +#: rayforge/image/bitmap/importer.py +#, python-brace-format +msgid "Could not read the {format} image: {error}" +msgstr "Не вдалося прочитати зображення {format}: {error}" + #: rayforge/image/pdf/pdf_trace.py rayforge/image/pdf/pdf_vector.py msgid "PDF file contains no pages." msgstr "PDF-файл не містить сторінок." @@ -10346,57 +10736,60 @@ msgid "Floyd Steinberg" msgstr "Floyd Steinberg" #: rayforge/image/dither.py -msgid "Bayer 2" -msgstr "Bayer 2" +msgid "Atkinson" +msgstr "Atkinson" #: rayforge/image/dither.py -msgid "Bayer 4" -msgstr "Bayer 4" +msgid "Stucki" +msgstr "Stucki" #: rayforge/image/dither.py -msgid "Bayer 8" -msgstr "Bayer 8" +msgid "Jarvis, Judice & Ninke" +msgstr "Jarvis, Judice & Ninke" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "View Outside Workspace" -msgstr "Вигляд поза робочою областю" +#: rayforge/image/dither.py +msgid "Sierra" +msgstr "Sierra" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Show the camera image in a margin around the work area to help position workpieces. Requires image alignment." -msgstr "Показувати зображення камери в полі навколо робочої області, щоб допомогти розташувати заготовки. Потребує вирівнювання зображення." +#: rayforge/image/dither.py +msgid "Sierra 2-Row" +msgstr "Sierra 2-Row" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Show Outside Workspace" -msgstr "Показувати поза робочою областю" +#: rayforge/image/dither.py +msgid "Sierra Lite" +msgstr "Sierra Lite" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Draw the camera image around the work area" -msgstr "Малювати зображення камери навколо робочої області" +#: rayforge/image/dither.py +msgid "Burkes" +msgstr "Burkes" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Not shown until the camera image is aligned" -msgstr "Не показується, доки зображення камери не вирівняно" +#: rayforge/image/dither.py +msgid "Bayer 2" +msgstr "Bayer 2" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Margin" -msgstr "Поле" +#: rayforge/image/dither.py +msgid "Bayer 4" +msgstr "Bayer 4" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Size of the visible area on each side of the work area" -msgstr "Розмір видимої області з кожного боку робочої області" +#: rayforge/image/dither.py +msgid "Bayer 8" +msgstr "Bayer 8" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Transparency outside the work area" -msgstr "Прозорість поза робочою областю" +#: rayforge/image/dither.py +msgid "Newsprint" +msgstr "Газетний друк" -#: rayforge/image/bitmap/importer.py -#, python-brace-format -msgid "Could not read the {format} image: {error}" -msgstr "Не вдалося прочитати зображення {format}: {error}" +#: rayforge/image/dither.py +msgid "Halftone" +msgstr "Напівтон" #: rayforge/image/postscript/importer.py -msgid "Importing EPS and PostScript files requires Ghostscript, which was not found. Install Ghostscript and try again." -msgstr "Для імпорту файлів EPS і PostScript потрібен Ghostscript, але його не знайдено. Встановіть Ghostscript і спробуйте ще раз." +msgid "" +"Importing EPS and PostScript files requires Ghostscript, which was not " +"found. Install Ghostscript and try again." +msgstr "" +"Для імпорту файлів EPS і PostScript потрібен Ghostscript, але його не " +"знайдено. Встановіть Ghostscript і спробуйте ще раз." #: rayforge/image/postscript/importer.py #, python-brace-format @@ -10404,47 +10797,12 @@ msgid "Ghostscript could not convert {filename}: {error}" msgstr "Ghostscript не зміг перетворити {filename}: {error}" #: rayforge/image/postscript/importer.py -msgid "This Illustrator file was saved without PDF compatibility. Rayforge can only read it with Ghostscript, which was not found. Install Ghostscript, or save the file again in Illustrator with \"Create PDF Compatible File\" enabled." -msgstr "Цей файл Illustrator збережено без сумісності з PDF. Rayforge може прочитати його лише за допомогою Ghostscript, але його не знайдено. Встановіть Ghostscript або збережіть файл в Illustrator ще раз з увімкненим параметром «Створити PDF-сумісний файл»." - -#: rayforge/image/hpgl/importer.py -#, python-brace-format -msgid "Some HPGL instructions are not supported and were skipped: {names}" -msgstr "Деякі інструкції HPGL не підтримуються і були пропущені: {names}" - -#: rayforge/image/hpgl/importer.py -#, python-brace-format -msgid "{filename} contains no HPGL drawing commands." -msgstr "{filename} не містить команд малювання HPGL." - -#: rayforge/image/hpgl/importer.py -#, python-brace-format -msgid "Pen {number}" -msgstr "Перо {number}" - -#: rayforge/machine/driver/driver.py -msgid "Home Switch" -msgstr "Кінцевий вимикач" - -#: rayforge/machine/driver/driver.py -msgid "Probe" -msgstr "Зонд" - -#: rayforge/ui_gtk/machine/advanced_preferences_page.py -msgid "Z Homing" -msgstr "Паркування Z" - -#: rayforge/ui_gtk/machine/advanced_preferences_page.py msgid "" -"How the Z axis finds its reference: a home switch, " -"or the auto-focus probe" -msgstr "Як вісь Z знаходить свою точку відліку: за кінцевим вимикачем або зондом автофокусу" - -#: rayforge/ui_gtk/doceditor/frame_popover.py -msgid "Include Overscan Margin" -msgstr "Урахувати межі overscan" - -#: rayforge/ui_gtk/doceditor/frame_popover.py -msgid "" -"Frame the full machine path, including the extra engraving travel" -msgstr "Окреслює повний шлях верстата, включно з додатковим ходом гравіювання" +"This Illustrator file was saved without PDF compatibility. Rayforge can only " +"read it with Ghostscript, which was not found. Install Ghostscript, or save " +"the file again in Illustrator with \"Create PDF Compatible File\" enabled." +msgstr "" +"Цей файл Illustrator збережено без сумісності з PDF. Rayforge може прочитати " +"його лише за допомогою Ghostscript, але його не знайдено. Встановіть " +"Ghostscript або збережіть файл в Illustrator ще раз з увімкненим параметром " +"«Створити PDF-сумісний файл»." diff --git a/rayforge/locale/zh_CN/LC_MESSAGES/rayforge.po b/rayforge/locale/zh_CN/LC_MESSAGES/rayforge.po index 711fd93db..afca0efec 100644 --- a/rayforge/locale/zh_CN/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/zh_CN/LC_MESSAGES/rayforge.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: Rayforge\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-10-06 22:41+0200\n" +"POT-Creation-Date: 2026-10-15 02:25+0200\n" "PO-Revision-Date: 2025-07-13 11:49+0200\n" "Last-Translator: Samuel Abels \n" "Language-Team: Chinese (Simplified)\n" @@ -436,6 +436,16 @@ msgid "" msgstr "" "选择要使用的传输方式。自动模式会在 USB 可用时打开 USB,否则回退到网络。" +#: rayforge/machine/driver/ruidarpa/rpa_adapter.py +msgid "TCP protocol" +msgstr "TCP 协议" + +#: rayforge/machine/driver/ruidarpa/rpa_adapter.py +msgid "" +"Use TCP instead of UDP for the network connection. Needed by controllers " +"that do not respond over UDP." +msgstr "使用 TCP 而非 UDP 进行网络连接。适用于不通过 UDP 响应的控制器。" + #: rayforge/machine/driver/ruidarpa/rpa_adapter.py #: rayforge/machine/driver/octoprint/octoprint_driver.py #: rayforge/machine/driver/grbl/grbl_telnet.py @@ -603,6 +613,14 @@ msgid "" msgstr "" "此驱动程序为实验性质,几乎可以确定存在错误。可能无法可靠运行。使用风险自负。" +#: rayforge/machine/driver/driver.py +msgid "Home Switch" +msgstr "原点开关" + +#: rayforge/machine/driver/driver.py +msgid "Probe" +msgstr "探针" + #: rayforge/machine/driver/driver.py rayforge/machine/transport/transport.py #: rayforge/ui_gtk/machine/machine_dropdown.py #: rayforge/ui_gtk/machine/status_widget.py @@ -2492,6 +2510,7 @@ msgstr "此机器配置文件及其所有设置将被永久删除。此操作无 #: rayforge/ui_gtk/doceditor/step_settings/recipe_control_widget.py #: rayforge/ui_gtk/doceditor/material_library_list.py #: rayforge/ui_gtk/doceditor/add_material_dialog.py +#: rayforge/ui_gtk/doceditor/path_tolerance_dialog.py #: rayforge/ui_gtk/doceditor/step_type_selection_dialog.py #: rayforge/ui_gtk/doceditor/material_selector.py #: rayforge/ui_gtk/doceditor/material_list.py @@ -2715,16 +2734,16 @@ msgstr "安装、更新和移除插件。" #: rayforge/ui_gtk/settings/gesture_preferences_page.py msgid "Mouse Gestures" -msgstr "" +msgstr "鼠标手势" #: rayforge/ui_gtk/settings/gesture_preferences_page.py #, python-format msgid "Default: %s" -msgstr "" +msgstr "默认:%s" #: rayforge/ui_gtk/settings/gesture_preferences_page.py msgid "Unassigned" -msgstr "" +msgstr "未分配" #: rayforge/ui_gtk/settings/recipe_manager_page.py msgid "Recipes" @@ -2994,6 +3013,7 @@ msgid "Length" msgstr "长度" #: rayforge/ui_gtk/settings/general_preferences_page.py +#: rayforge/ui_gtk/machine/bidir_offset_table.py #: rayforge/ui_gtk/doceditor/bottom_panel.py msgid "Speed" msgstr "速度" @@ -3028,6 +3048,14 @@ msgstr "检查更新" msgid "Automatically check for new Rayforge versions on startup" msgstr "启动时自动检查新版本的 Rayforge" +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Watch imported files" +msgstr "监视导入的文件" + +#: rayforge/ui_gtk/settings/general_preferences_page.py +msgid "Offer to reload imported files when they change on disk" +msgstr "当导入的文件在磁盘上发生更改时,提示重新加载" + #: rayforge/ui_gtk/settings/general_preferences_page.py msgid "Startup behavior" msgstr "启动行为" @@ -3203,10 +3231,42 @@ msgstr "编组" msgid "Ungroup" msgstr "取消编组" +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +msgid "Break Apart" +msgstr "拆分" + +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +msgid "Close Paths…" +msgstr "闭合路径…" + +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +msgid "Join Open Paths…" +msgstr "连接开放路径…" + +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +msgid "Delete Duplicates" +msgstr "删除重复项" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +msgid "Clean Up Paths" +msgstr "清理路径" + #: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/doceditor/stock_cmd.py msgid "Convert to Stock" msgstr "转换为材料" +#: rayforge/ui_gtk/canvas2d/context_menu.py rayforge/ui_gtk/main_menu.py +#: rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Reload from Disk" +msgstr "从磁盘重新加载" + +#: rayforge/ui_gtk/canvas2d/context_menu.py +#: rayforge/ui_gtk/doceditor/asset_browser.py +#: rayforge/ui_gtk/doceditor/layer_column.py +msgid "Relink Source File…" +msgstr "重新链接源文件…" + #: rayforge/ui_gtk/canvas2d/context_menu.py msgid "Add Tab Here" msgstr "在此添加标签" @@ -3273,6 +3333,56 @@ msgstr "添加毛坯" msgid "Auto Layout (Simple)" msgstr "自动布局(简单)" +#: rayforge/ui_gtk/actions.py +#, python-brace-format +msgid "The file of {name} cannot be found. Use Relink to pick it." +msgstr "找不到 {name} 的文件。请使用“重新链接”来选择它。" + +#: rayforge/ui_gtk/actions.py +msgid "Nothing to break apart" +msgstr "没有可拆分的内容" + +#: rayforge/ui_gtk/actions.py +#, python-brace-format +msgid "Closed {count} path" +msgid_plural "Closed {count} paths" +msgstr[0] "闭合了 {count} 条路径" + +#: rayforge/ui_gtk/actions.py +msgid "Close Paths" +msgstr "闭合路径" + +#: rayforge/ui_gtk/actions.py +msgid "" +"Closes open paths whose start and end point are closer than the tolerance." +msgstr "闭合起点和终点距离小于容差的开放路径。" + +#: rayforge/ui_gtk/actions.py +#, python-brace-format +msgid "Made {count} join" +msgid_plural "Made {count} joins" +msgstr[0] "建立了 {count} 个连接" + +#: rayforge/ui_gtk/actions.py +msgid "Join Open Paths" +msgstr "连接开放路径" + +#: rayforge/ui_gtk/actions.py +msgid "" +"Joins open paths whose end points are closer than the tolerance. Paths whose " +"ends then meet are closed." +msgstr "连接端点距离小于容差的开放路径。端点随后相接的路径将被闭合。" + +#: rayforge/ui_gtk/actions.py +msgid "Join Paths" +msgstr "连接路径" + +#: rayforge/ui_gtk/actions.py +#, python-brace-format +msgid "Deleted {count} duplicate" +msgid_plural "Deleted {count} duplicates" +msgstr[0] "删除了 {count} 个重复项" + #: rayforge/ui_gtk/camera/lens_calibration_dialog.py #, python-brace-format msgid "{camera_name} - Lens Calibration" @@ -3365,6 +3475,7 @@ msgid "Temporal averaging, higher values cause trailing" msgstr "时间平均,较高的值会导致拖尾" #: rayforge/ui_gtk/camera/image_settings_widget.py +#: rayforge/ui_gtk/camera/outside_view_widget.py msgid "Transparency" msgstr "透明度" @@ -4084,6 +4195,40 @@ msgstr "摄像头" msgid "Stream a camera image directly onto the work surface." msgstr "将摄像头图像直接流式传输到工作台。" +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "View Outside Workspace" +msgstr "工作区域外视图" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "" +"Show the camera image in a margin around the work area to help position " +"workpieces. Requires image alignment." +msgstr "在工作区域周围的边距中显示相机图像,以帮助定位工件。需要图像对齐。" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Show Outside Workspace" +msgstr "显示工作区域外部" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Draw the camera image around the work area" +msgstr "在工作区域周围绘制相机图像" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Margin" +msgstr "边距" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Size of the visible area on each side of the work area" +msgstr "工作区域每侧可见区域的大小" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Transparency outside the work area" +msgstr "工作区域外的透明度" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Not shown until the camera image is aligned" +msgstr "相机图像对齐后才会显示" + #: rayforge/ui_gtk/camera/alignment_widget.py msgid "" "Click the image to add reference points. Drag to move them.\n" @@ -5123,6 +5268,15 @@ msgstr "允许单轴回原点" msgid "Enable individual axis homing controls in the jog dialog" msgstr "在点动对话框中启用单独的轴回原点控制" +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "Z Homing" +msgstr "Z轴回原点" + +#: rayforge/ui_gtk/machine/advanced_preferences_page.py +msgid "" +"How the Z axis finds its reference: a home switch, or the auto-focus probe" +msgstr "Z轴如何找到参考位置:通过原点开关,或通过自动对焦探针" + #: rayforge/ui_gtk/machine/advanced_preferences_page.py msgid "Clear Alarm On Connect" msgstr "连接时清除报警" @@ -5558,6 +5712,44 @@ msgid "" "profile updates." msgstr "这些备注随此机器一起保存,设备配置文件更新绝不会覆盖它们。" +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "Offset" +msgstr "偏移" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "Positive values shift right-to-left passes to the right" +msgstr "正值会将从右到左的扫描向右偏移" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "Remove Row" +msgstr "移除行" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +#, python-brace-format +msgid "{speed}: {offset}" +msgstr "{speed}: {offset}" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "Bidirectional Scan Offset by Speed" +msgstr "按速度设置的双向扫描偏移" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "" +"Shift of right-to-left raster passes at each engrave speed, interpolated " +"between rows. Engrave steps use it when their own Bidirectional Scan Offset " +"is 0." +msgstr "" +"每种雕刻速度下从右到左光栅扫描的偏移,在行之间插值。当雕刻步骤自身的双向扫描" +"偏移为 0 时使用此值。" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "Add Row" +msgstr "添加行" + +#: rayforge/ui_gtk/machine/bidir_offset_table.py +msgid "No rows: all speeds use an offset of 0" +msgstr "无行:所有速度均使用偏移 0" + #: rayforge/ui_gtk/machine/wizard_pages/camera_page.py msgid "" "Optional. Configure any cameras you want to use for preview and alignment." @@ -7308,6 +7500,10 @@ msgstr "打开最近的文件" msgid "Import..." msgstr "导入..." +#: rayforge/ui_gtk/main_menu.py +msgid "Relink Source File..." +msgstr "重新链接源文件..." + #: rayforge/ui_gtk/main_menu.py msgid "Export G-code..." msgstr "导出G代码..." @@ -7503,7 +7699,7 @@ msgstr "排列" msgid "_Tools" msgstr "工具(_T)" -#: rayforge/ui_gtk/main_menu.py +#: rayforge/ui_gtk/main_menu.py rayforge/ui_gtk/doceditor/frame_popover.py msgid "Frame" msgstr "走边框" @@ -7737,79 +7933,79 @@ msgstr "发生意外错误:{error}" #: rayforge/ui_gtk/gestures/model.py msgid "Click" -msgstr "" +msgstr "单击" #: rayforge/ui_gtk/gestures/model.py msgid "Double-click" -msgstr "" +msgstr "双击" #: rayforge/ui_gtk/gestures/model.py msgid "Drag" -msgstr "" +msgstr "拖动" #: rayforge/ui_gtk/gestures/model.py msgid "Scroll" -msgstr "" +msgstr "滚动" #: rayforge/ui_gtk/gestures/model.py msgid "Middle" -msgstr "" +msgstr "中键" #: rayforge/ui_gtk/gestures/model.py msgid "Shift" -msgstr "" +msgstr "Shift" #: rayforge/ui_gtk/gestures/model.py msgid "Ctrl" -msgstr "" +msgstr "Ctrl" #: rayforge/ui_gtk/gestures/model.py msgid "Alt" -msgstr "" +msgstr "Alt" #: rayforge/ui_gtk/gestures/builtin.py msgid "2D Canvas" -msgstr "" +msgstr "2D 画布" #: rayforge/ui_gtk/gestures/builtin.py msgid "Navigation gestures on the 2D work surface." -msgstr "" +msgstr "2D 工作面上的导航手势。" #: rayforge/ui_gtk/gestures/builtin.py msgid "3D Canvas" -msgstr "" +msgstr "3D 画布" #: rayforge/ui_gtk/gestures/builtin.py msgid "Navigation gestures on the 3D preview." -msgstr "" +msgstr "3D 预览中的导航手势。" #: rayforge/ui_gtk/gestures/builtin.py msgid "Pan the view" -msgstr "" +msgstr "平移视图" #: rayforge/ui_gtk/gestures/builtin.py msgid "Hold the mouse button down and move to pan the view." -msgstr "" +msgstr "按住鼠标按键并移动以平移视图。" #: rayforge/ui_gtk/gestures/builtin.py msgid "Orbit the camera" -msgstr "" +msgstr "环绕相机" #: rayforge/ui_gtk/gestures/builtin.py msgid "Hold the mouse button down and move to orbit around the scene." -msgstr "" +msgstr "按住鼠标按键并移动以环绕场景。" #: rayforge/ui_gtk/gestures/builtin.py msgid "Pan the camera" -msgstr "" +msgstr "平移相机" #: rayforge/ui_gtk/gestures/builtin.py msgid "Hold the mouse button down and move to pan the scene." -msgstr "" +msgstr "按住鼠标按键并移动以平移场景。" #: rayforge/ui_gtk/gestures/builtin.py msgid "Rotate around the Z axis" -msgstr "" +msgstr "绕 Z 轴旋转" #: rayforge/ui_gtk/project_cmd.py msgid "Unsaved Changes" @@ -8265,10 +8461,23 @@ msgstr "输入新库文件夹的名称:" msgid "Failed to create library. A folder with that name may already exist." msgstr "创建库失败。具有该名称的文件夹可能已存在。" +#: rayforge/ui_gtk/doceditor/frame_popover.py +msgid "Include Overscan Margin" +msgstr "包含过冲边距" + +#: rayforge/ui_gtk/doceditor/frame_popover.py +msgid "Frame the full machine path, including the extra engraving travel" +msgstr "沿整个机器路径走边框,包括额外的雕刻行程" + #: rayforge/ui_gtk/doceditor/file_dialogs.py msgid "Open File" msgstr "打开文件" +#: rayforge/ui_gtk/doceditor/file_dialogs.py +#, python-brace-format +msgid "Relink “{name}”" +msgstr "重新链接“{name}”" + #: rayforge/ui_gtk/doceditor/file_dialogs.py msgid "All supported" msgstr "所有支持的格式" @@ -8389,6 +8598,10 @@ msgstr "自定义" msgid "Add New Layer" msgstr "添加新图层" +#: rayforge/ui_gtk/doceditor/path_tolerance_dialog.py +msgid "Tolerance" +msgstr "容差" + #: rayforge/ui_gtk/doceditor/stock_properties_dialog.py #: rayforge/ui_gtk/doceditor/item_properties.py msgid "Stock Properties" @@ -8524,6 +8737,37 @@ msgstr "必须连接机器才能在此处归零" msgid "Set current position as 0" msgstr "将当前位置设置为0" +#: rayforge/ui_gtk/doceditor/source_changed_dialog.py +msgid "Imported File Changed" +msgid_plural "Imported Files Changed" +msgstr[0] "导入的文件已更改" + +#: rayforge/ui_gtk/doceditor/source_changed_dialog.py +#, python-brace-format +msgid "" +"\"{name}\" was changed on disk. Reload it? Position, size and settings are " +"kept." +msgstr "“{name}”在磁盘上已更改。是否重新加载?位置、大小和设置将保留。" + +#: rayforge/ui_gtk/doceditor/source_changed_dialog.py +msgid "" +"These imported files were changed on disk. Reload them? Position, size and " +"settings are kept." +msgstr "这些导入的文件在磁盘上已更改。是否重新加载?位置、大小和设置将保留。" + +#: rayforge/ui_gtk/doceditor/source_changed_dialog.py +msgid "_Ignore" +msgstr "_忽略" + +#: rayforge/ui_gtk/doceditor/source_changed_dialog.py +msgid "_Reload" +msgstr "_重新加载" + +#: rayforge/ui_gtk/doceditor/source_changed_dialog.py +msgid "Always reload this file without asking" +msgid_plural "Always reload the selected files without asking" +msgstr[0] "始终重新加载所选文件而不询问" + #: rayforge/ui_gtk/doceditor/step_type_selection_dialog.py msgid "Select Step Types" msgstr "选择步骤类型" @@ -9717,6 +9961,69 @@ msgstr "重新排列工件" msgid "Reorder items" msgstr "重新排序项目" +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "{name} ({x:.1f}, {y:.1f} mm)" +msgstr "{name} ({x:.1f}, {y:.1f} mm)" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "Could not reload {name}: {error}" +msgstr "无法重新加载 {name}:{error}" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "Reloaded {name}" +msgstr "已重新加载 {name}" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "{n} new element added" +msgid_plural "{n} new elements added" +msgstr[0] "添加了 {n} 个新元素" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "{n} element no longer in the file was removed" +msgid_plural "{n} elements no longer in the file were removed" +msgstr[0] "移除了文件里已不存在的 {n} 个元素" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "{n} edited element was left unchanged" +msgid_plural "{n} edited elements were left unchanged" +msgstr[0] "{n} 个已编辑的元素保持不变" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "and {n} more" +msgstr "以及另外 {n} 个" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "" +"{n} tab more than {limit:.1f} mm from the new outline was removed: {places}" +msgid_plural "" +"{n} tabs more than {limit:.1f} mm from the new outline were removed: {places}" +msgstr[0] "移除了距新轮廓超过 {limit:.1f} mm 的 {n} 个连接片:{places}" + +#: rayforge/doceditor/reload_cmd.py +msgid "the file type is not known" +msgstr "文件类型未知" + +#: rayforge/doceditor/reload_cmd.py +msgid "the file could not be read" +msgstr "无法读取文件" + +#: rayforge/doceditor/reload_cmd.py +msgid "the file contains nothing to import" +msgstr "文件中没有可导入的内容" + +#: rayforge/doceditor/reload_cmd.py +#, python-brace-format +msgid "Reload {name}" +msgstr "重新加载 {name}" + #: rayforge/doceditor/array_cmd.py msgid "Create Array" msgstr "创建阵列" @@ -9725,6 +10032,22 @@ msgstr "创建阵列" msgid "Create array copy" msgstr "创建阵列副本" +#: rayforge/doceditor/path_cleanup_cmd.py +msgid "Close paths" +msgstr "闭合路径" + +#: rayforge/doceditor/path_cleanup_cmd.py +msgid "Join paths" +msgstr "连接路径" + +#: rayforge/doceditor/path_cleanup_cmd.py +msgid "Delete duplicates" +msgstr "删除重复项" + +#: rayforge/doceditor/path_cleanup_cmd.py +msgid "Break apart" +msgstr "拆分" + #: rayforge/doceditor/editor.py #, python-brace-format msgid "{count} asset(s) require disabled addon '{addon}'" @@ -9802,11 +10125,27 @@ msgstr "定位在点" msgid "Auto Layout" msgstr "自动布局" +#: rayforge/image/hpgl/importer.py +#, python-brace-format +msgid "Some HPGL instructions are not supported and were skipped: {names}" +msgstr "部分 HPGL 指令不受支持,已跳过:{names}" + +#: rayforge/image/hpgl/importer.py +#, python-brace-format +msgid "{filename} contains no HPGL drawing commands." +msgstr "{filename} 不包含 HPGL 绘图命令。" + +#: rayforge/image/hpgl/importer.py +#, python-brace-format +msgid "Pen {number}" +msgstr "笔 {number}" + #: rayforge/image/png/importer.py msgid "Failed to scan PNG file: {}" msgstr "扫描PNG文件失败:{}" -#: rayforge/image/png/importer.py rayforge/image/jpg/importer.py +#: rayforge/image/png/importer.py rayforge/image/bitmap/importer.py +#: rayforge/image/jpg/importer.py msgid "Failed to process image data." msgstr "处理图像数据失败。" @@ -9842,6 +10181,15 @@ msgstr "光栅化SVG以进行描摹失败。" msgid "Failed to normalize image data." msgstr "归一化图像数据失败。" +#: rayforge/image/svg/importer.py +msgid "This SVGZ file's gzip data could not be decompressed." +msgstr "" + +#: rayforge/image/bitmap/importer.py +#, python-brace-format +msgid "Could not read the {format} image: {error}" +msgstr "无法读取 {format} 图像:{error}" + #: rayforge/image/pdf/pdf_trace.py rayforge/image/pdf/pdf_vector.py msgid "PDF file contains no pages." msgstr "PDF文件不包含页面。" @@ -9947,57 +10295,60 @@ msgid "Floyd Steinberg" msgstr "Floyd Steinberg" #: rayforge/image/dither.py -msgid "Bayer 2" -msgstr "Bayer 2" +msgid "Atkinson" +msgstr "Atkinson" #: rayforge/image/dither.py -msgid "Bayer 4" -msgstr "Bayer 4" +msgid "Stucki" +msgstr "Stucki" #: rayforge/image/dither.py -msgid "Bayer 8" -msgstr "Bayer 8" +msgid "Jarvis, Judice & Ninke" +msgstr "Jarvis, Judice & Ninke" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "View Outside Workspace" -msgstr "工作区域外视图" +#: rayforge/image/dither.py +msgid "Sierra" +msgstr "Sierra" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Show the camera image in a margin around the work area to help position workpieces. Requires image alignment." -msgstr "在工作区域周围的边距中显示相机图像,以帮助定位工件。需要图像对齐。" +#: rayforge/image/dither.py +msgid "Sierra 2-Row" +msgstr "Sierra 2-Row" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Show Outside Workspace" -msgstr "显示工作区域外部" +#: rayforge/image/dither.py +msgid "Sierra Lite" +msgstr "Sierra Lite" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Draw the camera image around the work area" -msgstr "在工作区域周围绘制相机图像" +#: rayforge/image/dither.py +msgid "Burkes" +msgstr "Burkes" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Not shown until the camera image is aligned" -msgstr "相机图像对齐后才会显示" +#: rayforge/image/dither.py +msgid "Bayer 2" +msgstr "Bayer 2" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Margin" -msgstr "边距" +#: rayforge/image/dither.py +msgid "Bayer 4" +msgstr "Bayer 4" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Size of the visible area on each side of the work area" -msgstr "工作区域每侧可见区域的大小" +#: rayforge/image/dither.py +msgid "Bayer 8" +msgstr "Bayer 8" -#: rayforge/ui_gtk/camera/outside_view_widget.py -msgid "Transparency outside the work area" -msgstr "工作区域外的透明度" +#: rayforge/image/dither.py +msgid "Newsprint" +msgstr "报纸印刷" -#: rayforge/image/bitmap/importer.py -#, python-brace-format -msgid "Could not read the {format} image: {error}" -msgstr "无法读取 {format} 图像:{error}" +#: rayforge/image/dither.py +msgid "Halftone" +msgstr "半色调" #: rayforge/image/postscript/importer.py -msgid "Importing EPS and PostScript files requires Ghostscript, which was not found. Install Ghostscript and try again." -msgstr "导入 EPS 和 PostScript 文件需要 Ghostscript,但未找到。请安装 Ghostscript 后重试。" +msgid "" +"Importing EPS and PostScript files requires Ghostscript, which was not " +"found. Install Ghostscript and try again." +msgstr "" +"导入 EPS 和 PostScript 文件需要 Ghostscript,但未找到。请安装 Ghostscript 后" +"重试。" #: rayforge/image/postscript/importer.py #, python-brace-format @@ -10005,47 +10356,11 @@ msgid "Ghostscript could not convert {filename}: {error}" msgstr "Ghostscript 无法转换 {filename}:{error}" #: rayforge/image/postscript/importer.py -msgid "This Illustrator file was saved without PDF compatibility. Rayforge can only read it with Ghostscript, which was not found. Install Ghostscript, or save the file again in Illustrator with \"Create PDF Compatible File\" enabled." -msgstr "此 Illustrator 文件保存时未启用 PDF 兼容。Rayforge 只能借助 Ghostscript 读取它,但未找到 Ghostscript。请安装 Ghostscript,或在 Illustrator 中启用“创建 PDF 兼容文件”后重新保存该文件。" - -#: rayforge/image/hpgl/importer.py -#, python-brace-format -msgid "Some HPGL instructions are not supported and were skipped: {names}" -msgstr "部分 HPGL 指令不受支持,已跳过:{names}" - -#: rayforge/image/hpgl/importer.py -#, python-brace-format -msgid "{filename} contains no HPGL drawing commands." -msgstr "{filename} 不包含 HPGL 绘图命令。" - -#: rayforge/image/hpgl/importer.py -#, python-brace-format -msgid "Pen {number}" -msgstr "笔 {number}" - -#: rayforge/machine/driver/driver.py -msgid "Home Switch" -msgstr "原点开关" - -#: rayforge/machine/driver/driver.py -msgid "Probe" -msgstr "探针" - -#: rayforge/ui_gtk/machine/advanced_preferences_page.py -msgid "Z Homing" -msgstr "Z轴回原点" - -#: rayforge/ui_gtk/machine/advanced_preferences_page.py -msgid "" -"How the Z axis finds its reference: a home switch, " -"or the auto-focus probe" -msgstr "Z轴如何找到参考位置:通过原点开关,或通过自动对焦探针" - -#: rayforge/ui_gtk/doceditor/frame_popover.py -msgid "Include Overscan Margin" -msgstr "包含过冲边距" - -#: rayforge/ui_gtk/doceditor/frame_popover.py msgid "" -"Frame the full machine path, including the extra engraving travel" -msgstr "沿整个机器路径走边框,包括额外的雕刻行程" +"This Illustrator file was saved without PDF compatibility. Rayforge can only " +"read it with Ghostscript, which was not found. Install Ghostscript, or save " +"the file again in Illustrator with \"Create PDF Compatible File\" enabled." +msgstr "" +"此 Illustrator 文件保存时未启用 PDF 兼容。Rayforge 只能借助 Ghostscript 读取" +"它,但未找到 Ghostscript。请安装 Ghostscript,或在 Illustrator 中启用“创建 " +"PDF 兼容文件”后重新保存该文件。" From 1bab19e6a2344a61670593db5ef4b3f6f7fe8265 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Thu, 15 Oct 2026 05:40:38 +0200 Subject: [PATCH 079/104] chore: leave the translation catalogs to the release update The new strings stay wrapped in _(); the .po/.pot files are updated once before each release. --- .../locale/de/LC_MESSAGES/post_processors.po | 36 ------------------- .../locale/en/LC_MESSAGES/post_processors.po | 33 ----------------- .../locale/es/LC_MESSAGES/post_processors.po | 35 ------------------ .../locale/fr/LC_MESSAGES/post_processors.po | 35 ------------------ .../locale/hi/LC_MESSAGES/post_processors.po | 33 ----------------- .../locale/post_processors.pot | 33 ----------------- .../locale/pt/LC_MESSAGES/post_processors.po | 35 ------------------ .../locale/uk/LC_MESSAGES/post_processors.po | 34 ------------------ .../zh_CN/LC_MESSAGES/post_processors.po | 33 ----------------- 9 files changed, 307 deletions(-) diff --git a/rayforge/builtin_addons/rayforge-addon-post/locale/de/LC_MESSAGES/post_processors.po b/rayforge/builtin_addons/rayforge-addon-post/locale/de/LC_MESSAGES/post_processors.po index 12557fe31..d181b503b 100644 --- a/rayforge/builtin_addons/rayforge-addon-post/locale/de/LC_MESSAGES/post_processors.po +++ b/rayforge/builtin_addons/rayforge-addon-post/locale/de/LC_MESSAGES/post_processors.po @@ -298,39 +298,3 @@ msgid "Creates holding tabs by adding gaps or reducing power on cut paths" msgstr "" "Erstellt Haltestege durch Hinzufügen von Lücken oder Reduzierung der " "Leistung auf Schneidpfaden" - -#: post_processors/transformers/perforation_transformer.py -msgid "Perforation" -msgstr "Perforation" - -#: post_processors/transformers/perforation_transformer.py -msgid "" -"Cuts in dashes: the laser fires for the cut length and skips the skip length" -msgstr "" -"Schneidet gestrichelt: Der Laser schneidet über die Schnittlänge und lässt " -"die Auslasslänge aus" - -#: post_processors/widgets/perforation_group.py -msgid "Cut Length" -msgstr "Schnittlänge" - -#: post_processors/widgets/perforation_group.py -msgid "Distance the laser fires before each gap" -msgstr "Strecke, die der Laser vor jeder Lücke schneidet" - -#: post_processors/widgets/perforation_group.py -msgid "Skip Length" -msgstr "Auslasslänge" - -#: post_processors/widgets/perforation_group.py -msgid "Distance travelled with the laser off between cuts" -msgstr "" -"Strecke, die zwischen den Schnitten mit ausgeschaltetem Laser gefahren wird" - -#: post_processors/widgets/perforation_group.py -msgid "Change perforation cut length" -msgstr "Perforations-Schnittlänge ändern" - -#: post_processors/widgets/perforation_group.py -msgid "Change perforation skip length" -msgstr "Perforations-Auslasslänge ändern" diff --git a/rayforge/builtin_addons/rayforge-addon-post/locale/en/LC_MESSAGES/post_processors.po b/rayforge/builtin_addons/rayforge-addon-post/locale/en/LC_MESSAGES/post_processors.po index 36c33035a..2b725acf4 100644 --- a/rayforge/builtin_addons/rayforge-addon-post/locale/en/LC_MESSAGES/post_processors.po +++ b/rayforge/builtin_addons/rayforge-addon-post/locale/en/LC_MESSAGES/post_processors.po @@ -283,36 +283,3 @@ msgstr "Tabs" #: post_processors/transformers/tabs_transformer.py msgid "Creates holding tabs by adding gaps or reducing power on cut paths" msgstr "Creates holding tabs by adding gaps or reducing power on cut paths" - -#: post_processors/transformers/perforation_transformer.py -msgid "Perforation" -msgstr "" - -#: post_processors/transformers/perforation_transformer.py -msgid "" -"Cuts in dashes: the laser fires for the cut length and skips the skip length" -msgstr "" - -#: post_processors/widgets/perforation_group.py -msgid "Cut Length" -msgstr "" - -#: post_processors/widgets/perforation_group.py -msgid "Distance the laser fires before each gap" -msgstr "" - -#: post_processors/widgets/perforation_group.py -msgid "Skip Length" -msgstr "" - -#: post_processors/widgets/perforation_group.py -msgid "Distance travelled with the laser off between cuts" -msgstr "" - -#: post_processors/widgets/perforation_group.py -msgid "Change perforation cut length" -msgstr "" - -#: post_processors/widgets/perforation_group.py -msgid "Change perforation skip length" -msgstr "" diff --git a/rayforge/builtin_addons/rayforge-addon-post/locale/es/LC_MESSAGES/post_processors.po b/rayforge/builtin_addons/rayforge-addon-post/locale/es/LC_MESSAGES/post_processors.po index 6274a9653..cff26d78e 100644 --- a/rayforge/builtin_addons/rayforge-addon-post/locale/es/LC_MESSAGES/post_processors.po +++ b/rayforge/builtin_addons/rayforge-addon-post/locale/es/LC_MESSAGES/post_processors.po @@ -297,38 +297,3 @@ msgid "Creates holding tabs by adding gaps or reducing power on cut paths" msgstr "" "Crea pestañas de sujeción añadiendo huecos o reduciendo potencia en " "trayectorias de corte" - -#: post_processors/transformers/perforation_transformer.py -msgid "Perforation" -msgstr "Perforación" - -#: post_processors/transformers/perforation_transformer.py -msgid "" -"Cuts in dashes: the laser fires for the cut length and skips the skip length" -msgstr "" -"Corta en trazos: el láser dispara durante la longitud de corte y salta la " -"longitud de salto" - -#: post_processors/widgets/perforation_group.py -msgid "Cut Length" -msgstr "Longitud de corte" - -#: post_processors/widgets/perforation_group.py -msgid "Distance the laser fires before each gap" -msgstr "Distancia que dispara el láser antes de cada hueco" - -#: post_processors/widgets/perforation_group.py -msgid "Skip Length" -msgstr "Longitud de salto" - -#: post_processors/widgets/perforation_group.py -msgid "Distance travelled with the laser off between cuts" -msgstr "Distancia recorrida con el láser apagado entre cortes" - -#: post_processors/widgets/perforation_group.py -msgid "Change perforation cut length" -msgstr "Cambiar longitud de corte de perforación" - -#: post_processors/widgets/perforation_group.py -msgid "Change perforation skip length" -msgstr "Cambiar longitud de salto de perforación" diff --git a/rayforge/builtin_addons/rayforge-addon-post/locale/fr/LC_MESSAGES/post_processors.po b/rayforge/builtin_addons/rayforge-addon-post/locale/fr/LC_MESSAGES/post_processors.po index dd5c2375b..445349d76 100644 --- a/rayforge/builtin_addons/rayforge-addon-post/locale/fr/LC_MESSAGES/post_processors.po +++ b/rayforge/builtin_addons/rayforge-addon-post/locale/fr/LC_MESSAGES/post_processors.po @@ -302,38 +302,3 @@ msgid "Creates holding tabs by adding gaps or reducing power on cut paths" msgstr "" "Crée des languettes de maintien en ajoutant des espaces ou en réduisant la " "puissance sur les parcours de coupe" - -#: post_processors/transformers/perforation_transformer.py -msgid "Perforation" -msgstr "Perforation" - -#: post_processors/transformers/perforation_transformer.py -msgid "" -"Cuts in dashes: the laser fires for the cut length and skips the skip length" -msgstr "" -"Découpe en pointillés : le laser tire sur la longueur de coupe et saute la " -"longueur de saut" - -#: post_processors/widgets/perforation_group.py -msgid "Cut Length" -msgstr "Longueur de coupe" - -#: post_processors/widgets/perforation_group.py -msgid "Distance the laser fires before each gap" -msgstr "Distance sur laquelle le laser tire avant chaque interruption" - -#: post_processors/widgets/perforation_group.py -msgid "Skip Length" -msgstr "Longueur de saut" - -#: post_processors/widgets/perforation_group.py -msgid "Distance travelled with the laser off between cuts" -msgstr "Distance parcourue laser éteint entre les coupes" - -#: post_processors/widgets/perforation_group.py -msgid "Change perforation cut length" -msgstr "Modifier la longueur de coupe de la perforation" - -#: post_processors/widgets/perforation_group.py -msgid "Change perforation skip length" -msgstr "Modifier la longueur de saut de la perforation" diff --git a/rayforge/builtin_addons/rayforge-addon-post/locale/hi/LC_MESSAGES/post_processors.po b/rayforge/builtin_addons/rayforge-addon-post/locale/hi/LC_MESSAGES/post_processors.po index 431bcad31..db86626fb 100644 --- a/rayforge/builtin_addons/rayforge-addon-post/locale/hi/LC_MESSAGES/post_processors.po +++ b/rayforge/builtin_addons/rayforge-addon-post/locale/hi/LC_MESSAGES/post_processors.po @@ -285,36 +285,3 @@ msgstr "टैब" #: post_processors/transformers/tabs_transformer.py msgid "Creates holding tabs by adding gaps or reducing power on cut paths" msgstr "कट पाथ पर अंतराल जोड़कर या शक्ति घटाकर होल्डिंग टैब बनाता है" - -#: post_processors/transformers/perforation_transformer.py -msgid "Perforation" -msgstr "परफोरेशन" - -#: post_processors/transformers/perforation_transformer.py -msgid "" -"Cuts in dashes: the laser fires for the cut length and skips the skip length" -msgstr "डैश में काटता है: लेज़र कट लंबाई तक चलता है और स्किप लंबाई छोड़ देता है" - -#: post_processors/widgets/perforation_group.py -msgid "Cut Length" -msgstr "कट लंबाई" - -#: post_processors/widgets/perforation_group.py -msgid "Distance the laser fires before each gap" -msgstr "हर गैप से पहले लेज़र जितनी दूरी तक चलता है" - -#: post_processors/widgets/perforation_group.py -msgid "Skip Length" -msgstr "स्किप लंबाई" - -#: post_processors/widgets/perforation_group.py -msgid "Distance travelled with the laser off between cuts" -msgstr "कटों के बीच लेज़र बंद करके तय की गई दूरी" - -#: post_processors/widgets/perforation_group.py -msgid "Change perforation cut length" -msgstr "परफोरेशन कट लंबाई बदलें" - -#: post_processors/widgets/perforation_group.py -msgid "Change perforation skip length" -msgstr "परफोरेशन स्किप लंबाई बदलें" diff --git a/rayforge/builtin_addons/rayforge-addon-post/locale/post_processors.pot b/rayforge/builtin_addons/rayforge-addon-post/locale/post_processors.pot index 95b43fa9c..1c533abba 100644 --- a/rayforge/builtin_addons/rayforge-addon-post/locale/post_processors.pot +++ b/rayforge/builtin_addons/rayforge-addon-post/locale/post_processors.pot @@ -283,36 +283,3 @@ msgstr "" #: post_processors/transformers/tabs_transformer.py msgid "Creates holding tabs by adding gaps or reducing power on cut paths" msgstr "" - -#: post_processors/transformers/perforation_transformer.py -msgid "Perforation" -msgstr "" - -#: post_processors/transformers/perforation_transformer.py -msgid "" -"Cuts in dashes: the laser fires for the cut length and skips the skip length" -msgstr "" - -#: post_processors/widgets/perforation_group.py -msgid "Cut Length" -msgstr "" - -#: post_processors/widgets/perforation_group.py -msgid "Distance the laser fires before each gap" -msgstr "" - -#: post_processors/widgets/perforation_group.py -msgid "Skip Length" -msgstr "" - -#: post_processors/widgets/perforation_group.py -msgid "Distance travelled with the laser off between cuts" -msgstr "" - -#: post_processors/widgets/perforation_group.py -msgid "Change perforation cut length" -msgstr "" - -#: post_processors/widgets/perforation_group.py -msgid "Change perforation skip length" -msgstr "" diff --git a/rayforge/builtin_addons/rayforge-addon-post/locale/pt/LC_MESSAGES/post_processors.po b/rayforge/builtin_addons/rayforge-addon-post/locale/pt/LC_MESSAGES/post_processors.po index a0dc872f4..4148e83f2 100644 --- a/rayforge/builtin_addons/rayforge-addon-post/locale/pt/LC_MESSAGES/post_processors.po +++ b/rayforge/builtin_addons/rayforge-addon-post/locale/pt/LC_MESSAGES/post_processors.po @@ -295,38 +295,3 @@ msgid "Creates holding tabs by adding gaps or reducing power on cut paths" msgstr "" "Cria abas de fixação adicionando lacunas ou reduzindo a potência em caminhos " "de corte" - -#: post_processors/transformers/perforation_transformer.py -msgid "Perforation" -msgstr "Perfuração" - -#: post_processors/transformers/perforation_transformer.py -msgid "" -"Cuts in dashes: the laser fires for the cut length and skips the skip length" -msgstr "" -"Corta em traços: o laser dispara pelo comprimento de corte e pula o " -"comprimento de salto" - -#: post_processors/widgets/perforation_group.py -msgid "Cut Length" -msgstr "Comprimento de corte" - -#: post_processors/widgets/perforation_group.py -msgid "Distance the laser fires before each gap" -msgstr "Distância em que o laser dispara antes de cada intervalo" - -#: post_processors/widgets/perforation_group.py -msgid "Skip Length" -msgstr "Comprimento de salto" - -#: post_processors/widgets/perforation_group.py -msgid "Distance travelled with the laser off between cuts" -msgstr "Distância percorrida com o laser desligado entre os cortes" - -#: post_processors/widgets/perforation_group.py -msgid "Change perforation cut length" -msgstr "Alterar comprimento de corte da perfuração" - -#: post_processors/widgets/perforation_group.py -msgid "Change perforation skip length" -msgstr "Alterar comprimento de salto da perfuração" diff --git a/rayforge/builtin_addons/rayforge-addon-post/locale/uk/LC_MESSAGES/post_processors.po b/rayforge/builtin_addons/rayforge-addon-post/locale/uk/LC_MESSAGES/post_processors.po index dfdf97cf2..d9d336910 100644 --- a/rayforge/builtin_addons/rayforge-addon-post/locale/uk/LC_MESSAGES/post_processors.po +++ b/rayforge/builtin_addons/rayforge-addon-post/locale/uk/LC_MESSAGES/post_processors.po @@ -295,37 +295,3 @@ msgid "Creates holding tabs by adding gaps or reducing power on cut paths" msgstr "" "Створює утримувальні закладки шляхом додавання проміжків або зменшення " "потужності на контурах різання" - -#: post_processors/transformers/perforation_transformer.py -msgid "Perforation" -msgstr "Перфорація" - -#: post_processors/transformers/perforation_transformer.py -msgid "" -"Cuts in dashes: the laser fires for the cut length and skips the skip length" -msgstr "" -"Ріже пунктиром: лазер ріже на довжині різу й пропускає довжину пропуску" - -#: post_processors/widgets/perforation_group.py -msgid "Cut Length" -msgstr "Довжина різу" - -#: post_processors/widgets/perforation_group.py -msgid "Distance the laser fires before each gap" -msgstr "Відстань, на якій лазер ріже перед кожним проміжком" - -#: post_processors/widgets/perforation_group.py -msgid "Skip Length" -msgstr "Довжина пропуску" - -#: post_processors/widgets/perforation_group.py -msgid "Distance travelled with the laser off between cuts" -msgstr "Відстань, яку лазер проходить вимкненим між різами" - -#: post_processors/widgets/perforation_group.py -msgid "Change perforation cut length" -msgstr "Змінити довжину різу перфорації" - -#: post_processors/widgets/perforation_group.py -msgid "Change perforation skip length" -msgstr "Змінити довжину пропуску перфорації" diff --git a/rayforge/builtin_addons/rayforge-addon-post/locale/zh_CN/LC_MESSAGES/post_processors.po b/rayforge/builtin_addons/rayforge-addon-post/locale/zh_CN/LC_MESSAGES/post_processors.po index c76d73b95..5012afa25 100644 --- a/rayforge/builtin_addons/rayforge-addon-post/locale/zh_CN/LC_MESSAGES/post_processors.po +++ b/rayforge/builtin_addons/rayforge-addon-post/locale/zh_CN/LC_MESSAGES/post_processors.po @@ -282,36 +282,3 @@ msgstr "标签" #: post_processors/transformers/tabs_transformer.py msgid "Creates holding tabs by adding gaps or reducing power on cut paths" msgstr "通过添加间隙或降低切割路径上的功率来创建保持标签" - -#: post_processors/transformers/perforation_transformer.py -msgid "Perforation" -msgstr "穿孔" - -#: post_processors/transformers/perforation_transformer.py -msgid "" -"Cuts in dashes: the laser fires for the cut length and skips the skip length" -msgstr "虚线切割:激光在切割长度内出光,并跳过跳过长度" - -#: post_processors/widgets/perforation_group.py -msgid "Cut Length" -msgstr "切割长度" - -#: post_processors/widgets/perforation_group.py -msgid "Distance the laser fires before each gap" -msgstr "每个间隙前激光出光的距离" - -#: post_processors/widgets/perforation_group.py -msgid "Skip Length" -msgstr "跳过长度" - -#: post_processors/widgets/perforation_group.py -msgid "Distance travelled with the laser off between cuts" -msgstr "两段切割之间关闭激光移动的距离" - -#: post_processors/widgets/perforation_group.py -msgid "Change perforation cut length" -msgstr "更改穿孔切割长度" - -#: post_processors/widgets/perforation_group.py -msgid "Change perforation skip length" -msgstr "更改穿孔跳过长度" From be8c16ba3c2673434912cc8ffecd4bfb1e83c2b1 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Thu, 15 Oct 2026 05:46:38 +0200 Subject: [PATCH 080/104] fix(post): keep the Tabs progress label for the tabs spec PerforationTransformer declared SPEC_NAME "tabs" and is registered before TabOpsTransformer, so the batch progress for every tabs spec was labelled "Perforation". Leave the spec name to the Tabs transformer. Refs #525 --- .../transformers/perforation_transformer.py | 1 - .../transformers/test_perforation_transformer.py | 14 +++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/rayforge/builtin_addons/rayforge-addon-post/post_processors/transformers/perforation_transformer.py b/rayforge/builtin_addons/rayforge-addon-post/post_processors/transformers/perforation_transformer.py index 5be0f4d66..e0a36a2a5 100644 --- a/rayforge/builtin_addons/rayforge-addon-post/post_processors/transformers/perforation_transformer.py +++ b/rayforge/builtin_addons/rayforge-addon-post/post_processors/transformers/perforation_transformer.py @@ -30,7 +30,6 @@ class PerforationTransformer(OpsTransformer): them out of the nearest toolpath. """ - SPEC_NAME = "tabs" DEFAULT_CUT_LENGTH = 2.0 DEFAULT_SKIP_LENGTH = 1.0 diff --git a/rayforge/builtin_addons/rayforge-addon-post/tests/transformers/test_perforation_transformer.py b/rayforge/builtin_addons/rayforge-addon-post/tests/transformers/test_perforation_transformer.py index ea3b40a44..15191561c 100644 --- a/rayforge/builtin_addons/rayforge-addon-post/tests/transformers/test_perforation_transformer.py +++ b/rayforge/builtin_addons/rayforge-addon-post/tests/transformers/test_perforation_transformer.py @@ -1,13 +1,17 @@ from unittest.mock import MagicMock import pytest -from post_processors.transformers import PerforationTransformer +from post_processors.transformers import ( + PerforationTransformer, + TabOpsTransformer, +) from raygeo.geo import Geometry from raygeo.ops import Ops from raygeo.ops.transform.tabs import TabsSpec from raygeo.ops.types import CommandType, SectionType from rayforge.core.workpiece import WorkPiece +from rayforge.pipeline.transformer.registry import TransformerRegistry def _unit_square(x0=0.0, y0=0.0, x1=1.0, y1=1.0) -> Geometry: @@ -183,3 +187,11 @@ def test_cut_and_skip_lengths_on_square(self): ops.preload_state() assert ops.cut_distance() == pytest.approx(60.0, abs=1e-6) assert _cut_runs(ops) == 40 + + +class TestPerforationProgressLabel: + def test_tabs_progress_keeps_the_tabs_label(self): + registry = TransformerRegistry() + registry.register(PerforationTransformer) + registry.register(TabOpsTransformer) + assert registry.progress_label("tabs") == TabOpsTransformer().label From e2e73efa8fb1a19d06164af49061b8172781bbbb Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Thu, 15 Oct 2026 05:47:12 +0200 Subject: [PATCH 081/104] refactor(post): bound the perforation rows by the transformer minimum Refs #525 --- .../post_processors/widgets/perforation_group.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/rayforge/builtin_addons/rayforge-addon-post/post_processors/widgets/perforation_group.py b/rayforge/builtin_addons/rayforge-addon-post/post_processors/widgets/perforation_group.py index e5ecdf504..5bd1135be 100644 --- a/rayforge/builtin_addons/rayforge-addon-post/post_processors/widgets/perforation_group.py +++ b/rayforge/builtin_addons/rayforge-addon-post/post_processors/widgets/perforation_group.py @@ -9,6 +9,7 @@ from rayforge.ui_gtk.shared.pref_rows import LengthSpinRow from ..transformers import PerforationTransformer +from ..transformers.perforation_transformer import MIN_LENGTH if TYPE_CHECKING: from rayforge.core.step import Step @@ -31,7 +32,7 @@ def __init__( self.cut_row = LengthSpinRow( _("Cut Length"), _("Distance the laser fires before each gap"), - lower=0.01, + lower=MIN_LENGTH, upper=1000.0, value_in_base=transformer.cut_length, ) @@ -43,7 +44,7 @@ def __init__( self.skip_row = LengthSpinRow( _("Skip Length"), _("Distance travelled with the laser off between cuts"), - lower=0.01, + lower=MIN_LENGTH, upper=1000.0, value_in_base=transformer.skip_length, ) From c1f7e6e7e7d33cfd71d1f7d513f7adcb511eccc5 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Thu, 15 Oct 2026 05:47:33 +0200 Subject: [PATCH 082/104] docs(sketcher): apply site-format to the polygon pages Refs #522 --- website/docs/features/sketcher/index.md | 14 +++++++------- website/docs/features/sketcher/polygon.md | 6 +++--- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/website/docs/features/sketcher/index.md b/website/docs/features/sketcher/index.md index db8f8702e..d961e0767 100644 --- a/website/docs/features/sketcher/index.md +++ b/website/docs/features/sketcher/index.md @@ -145,10 +145,10 @@ they get in the way. The drawing tools are each documented on their own page: [Path](path.md) (lines and bezier curves), [Arc and Ellipse](arc-ellipse.md), [Rectangle](rectangle.md) (and rounded rectangles), -[Polygon](polygon.md) (and stars), -[Fill Areas](fill.md), [Text Box](text-box.md), and [Grid](grid.md). Modifications such as -[Offset](offset.md) and [Chamfer and Fillet](chamfer-fillet.md) reshape existing geometry, -[Arrays](arrays.md) copies it along a circle or a curve, and -[Parameters and Expressions](expressions.md) explains how to add and edit parameters, drive geometry -and text boxes with them, and assign values per sketch instance. Sketches can be saved and -re-imported with all constraints intact — see [Import and Export](import-export.md). +[Polygon](polygon.md) (and stars), [Fill Areas](fill.md), [Text Box](text-box.md), and +[Grid](grid.md). Modifications such as [Offset](offset.md) and +[Chamfer and Fillet](chamfer-fillet.md) reshape existing geometry, [Arrays](arrays.md) copies it +along a circle or a curve, and [Parameters and Expressions](expressions.md) explains how to add and +edit parameters, drive geometry and text boxes with them, and assign values per sketch instance. +Sketches can be saved and re-imported with all constraints intact — see +[Import and Export](import-export.md). diff --git a/website/docs/features/sketcher/polygon.md b/website/docs/features/sketcher/polygon.md index f6c81d5b4..af21bb847 100644 --- a/website/docs/features/sketcher/polygon.md +++ b/website/docs/features/sketcher/polygon.md @@ -10,9 +10,9 @@ The **polygon** tool (`G+X`) draws a regular polygon, such as a hexagon for a co ## Drawing a Polygon -Click where the center should be, move the pointer and click again, or press at the center, drag -and release. The pointer sets the first corner, so it sets both the size and the rotation. A new -polygon has 6 corners. +Click where the center should be, move the pointer and click again, or press at the center, drag and +release. The pointer sets the first corner, so it sets both the size and the rotation. A new polygon +has 6 corners. - Hold `Ctrl` to draw a star instead: it adds an inner corner between every two outer corners, at half the outer radius. From 9678b0c8651deead0950ddb5137d2da86c213c73 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Thu, 15 Oct 2026 05:47:58 +0200 Subject: [PATCH 083/104] fix(sketcher): remove polygon preview lines through the registry Dropping the preview lines by hand left their point usage counts behind. Use EntityRegistry.remove_entities_by_id, as the rectangle preview does. Refs #522 --- .../sketcher/core/commands/polygon.py | 4 +--- .../tests/core/commands/test_polygon_cmd.py | 12 ++++++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/polygon.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/polygon.py index da191aa86..6e4e4e44c 100644 --- a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/polygon.py +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/polygon.py @@ -109,10 +109,8 @@ def _vertices(self, registry: EntityRegistry) -> list[GeoPoint]: ) def _remove_outline(self, registry: EntityRegistry) -> None: - lines = set(self.line_ids) corners = set(self.corner_ids) - registry.entities = [e for e in registry.entities if e.id not in lines] - registry._entity_map = {e.id: e for e in registry.entities} + registry.remove_entities_by_id(self.line_ids) registry.points = [p for p in registry.points if p.id not in corners] self.line_ids = [] self.corner_ids = [] diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_polygon_cmd.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_polygon_cmd.py index b8735f091..5d98112d1 100644 --- a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_polygon_cmd.py +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_polygon_cmd.py @@ -194,6 +194,18 @@ def test_changing_the_shape_rebuilds_the_preview(self): assert registry.entities == [] assert {p.id for p in registry.points} == {0, state.center_id} + def test_rebuilding_keeps_point_usage_counts(self): + sketch = Sketch() + registry = sketch.registry + state = PolygonCommand.start_preview(registry, 0.0, 0.0) + PolygonCommand.update_preview(registry, state, 10.0, 0.0) + old_corners = list(state.corner_ids) + state.set_shape(registry, sides=5, star_ratio=None) + assert not any(registry.is_point_used(p) for p in old_corners) + preview_ids = state.get_preview_point_ids() + PolygonCommand.cleanup_preview(registry, state) + assert not any(registry.is_point_used(p) for p in preview_ids) + def test_radius_input_scales_the_preview(self): sketch = Sketch() registry = sketch.registry From bd8ce6fa940d395dfdfe69c5b1f32bd33c369ff3 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Thu, 15 Oct 2026 05:49:37 +0200 Subject: [PATCH 084/104] fix(sketcher): join a polygon's first corner to a snapped point Like the circle and rectangle tools, a first corner placed on an existing point now reuses that point instead of adding a loose one on top of it. A typed radius still places a new corner. The first corner is now computed once, before the vertices, instead of building the vertices twice for a typed radius. Refs #522 --- .../sketcher/core/commands/polygon.py | 55 ++++++++++--------- .../sketcher/ui_gtk/tools/polygon_tool.py | 19 ++++++- .../tests/core/commands/test_polygon_cmd.py | 24 ++++++++ .../tests/ui_gtk/tools/test_polygon_tool.py | 11 ++++ 4 files changed, 81 insertions(+), 28 deletions(-) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/polygon.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/polygon.py index 6e4e4e44c..3ab72be14 100644 --- a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/polygon.py +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/polygon.py @@ -1,5 +1,6 @@ from __future__ import annotations +import itertools import math from collections.abc import Iterator from gettext import gettext as _ @@ -146,7 +147,7 @@ def set_radius(self, registry: EntityRegistry, radius: float) -> None: self.locked_radius = radius center = registry.get_point(self.center_id) first = registry.get_point(self.radius_id) - _place_at_radius(center, first, first.x, first.y, radius) + first.x, first.y = _at_radius(center, first.x, first.y, radius) self.reposition(registry) def get_dimensions(self, registry: EntityRegistry) -> list[DimensionData]: @@ -173,15 +174,15 @@ def get_dimensions(self, registry: EntityRegistry) -> list[DimensionData]: ] -def _place_at_radius( - center: Point, point: Point, x: float, y: float, radius: float -) -> None: +def _at_radius(center: Point, x: float, y: float, radius: float) -> GeoPoint: dx, dy = x - center.x, y - center.y length = math.hypot(dx, dy) if length < 1e-9: dx, dy, length = 1.0, 0.0, 1.0 - point.x = center.x + dx / length * radius - point.y = center.y + dy / length * radius + return ( + center.x + dx / length * radius, + center.y + dy / length * radius, + ) class PolygonCommand(SketchChangeCommand): @@ -196,6 +197,7 @@ def __init__( sketch: Sketch, center_id: EntityID, end_pos: GeoPoint, + end_pid: EntityID | None = None, sides: int = DEFAULT_SIDES, star_ratio: float | None = None, is_center_temp: bool = False, @@ -205,6 +207,7 @@ def __init__( super().__init__(sketch, _("Add Polygon")) self.center_id = center_id self.end_pos = end_pos + self.end_pid = end_pid if fixed_radius is None else None self.sides = sides self.star_ratio = star_ratio self.is_center_temp = is_center_temp @@ -252,7 +255,8 @@ def update_preview( except IndexError: return if preview_state.locked_radius is not None: - _place_at_radius(center, first, x, y, preview_state.locked_radius) + radius = preview_state.locked_radius + first.x, first.y = _at_radius(center, x, y, radius) else: first.x, first.y = x, y preview_state.reposition(registry) @@ -272,26 +276,27 @@ def cleanup_preview( p for p in registry.points if p.id != preview_state.radius_id ] + def _first_corner(self, center: Point) -> GeoPoint: + x, y = self.end_pos + if self.end_pid is not None: + end = self.sketch.registry.get_point(self.end_pid) + x, y = end.x, end.y + if self.fixed_radius is not None: + return _at_radius(center, x, y, self.fixed_radius) + return x, y + def _shape( - self, center: Point + self, center: Point, first: GeoPoint ) -> tuple[list[Point], list[Entity], list[Constraint]]: - temp_ids = iter(range(-2, -10_000, -1)) + temp_ids = itertools.count(-2, -1) vertices = polygon_vertices( - (center.x, center.y), self.end_pos, self.sides, self.star_ratio + (center.x, center.y), first, self.sides, self.star_ratio ) - if self.fixed_radius is not None: - first = Point(0, *vertices[0]) - _place_at_radius( - center, first, first.x, first.y, self.fixed_radius - ) - vertices = polygon_vertices( - (center.x, center.y), - (first.x, first.y), - self.sides, - self.star_ratio, - ) points = [Point(next(temp_ids), x, y) for x, y in vertices] ids = [p.id for p in points] + if self.end_pid is not None: + points.pop(0) + ids[0] = self.end_pid lines = [Line(next(temp_ids), a, b) for a, b in _ring(ids)] stride = 1 if self.star_ratio is None else 2 rings = [ids[0::stride]] @@ -326,15 +331,15 @@ def _do_execute(self) -> None: registry = self.sketch.registry try: center = registry.get_point(self.center_id) + first = self._first_corner(center) except IndexError: return - if math.hypot( - self.end_pos[0] - center.x, self.end_pos[1] - center.y - ) < 1e-9 and (self.fixed_radius is None): + radius = math.hypot(first[0] - center.x, first[1] - center.y) + if radius < 1e-9 or self.end_pid == self.center_id: if self.is_center_temp: self.sketch.remove_point_if_unused(self.center_id) return - points, entities, constraints = self._shape(center) + points, entities, constraints = self._shape(center, first) if self.is_center_temp: registry.points.remove(center) points.append(center) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/polygon_tool.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/polygon_tool.py index c8da558cc..6dcf62d91 100644 --- a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/polygon_tool.py +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/polygon_tool.py @@ -111,9 +111,17 @@ def on_press(self, world_x: float, world_y: float, n_press: int) -> bool: self.element.preview_changed.send(self.element) self.element.mark_dirty() else: - self._commit() + self._commit(end_pid=self._snapped_corner_id()) return True + def _snapped_corner_id(self) -> int | None: + """The snapped point the first corner lands on, if any.""" + pid = self.get_snapped_point_id() + state = self._preview_state + if state is None or pid in state.get_preview_point_ids(): + return None + return pid + def on_release(self, world_x: float, world_y: float): self._in_press = False if self._preview_state is None or self._press_world_pos is None: @@ -122,7 +130,7 @@ def on_release(self, world_x: float, world_y: float): dy = world_y - self._press_world_pos[1] if math.hypot(dx, dy) < self.DRAG_THRESHOLD: return - self._commit() + self._commit(end_pid=self._snapped_corner_id()) def on_hover_motion(self, world_x: float, world_y: float): """Updates the live preview of the polygon.""" @@ -165,7 +173,11 @@ def draw_overlay(self, ctx: cairo.Context): if self._preview_state is not None: self.draw_snap_feedback(ctx, self.element) - def _commit(self, fixed_radius: float | None = None) -> None: + def _commit( + self, + fixed_radius: float | None = None, + end_pid: int | None = None, + ) -> None: """Create the polygon the preview shows.""" state = self._preview_state if state is None: @@ -189,6 +201,7 @@ def _commit(self, fixed_radius: float | None = None) -> None: self.element.sketch, state.center_id, end_pos, + end_pid=end_pid, sides=sides, star_ratio=star_ratio, is_center_temp=state.center_temp, diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_polygon_cmd.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_polygon_cmd.py index 5d98112d1..7f0947313 100644 --- a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_polygon_cmd.py +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_polygon_cmd.py @@ -149,6 +149,30 @@ def test_dragging_a_corner_keeps_it_regular(self): ] assert radii == pytest.approx([radii[0]] * 6, rel=1e-4) + def test_snapped_first_corner_is_reused(self): + sketch = Sketch() + center = sketch.add_point(0, 0) + target = sketch.add_point(0, 10) + cmd = PolygonCommand(sketch, center, (0.0, 9.0), end_pid=target) + cmd.execute() + assert _lines(sketch)[0].p1_idx == target + assert _corner_positions(sketch)[1] == pytest.approx( + (-10 * math.sin(math.pi / 3), 5.0) + ) + assert len(sketch.registry.points) == 8 + cmd.undo() + assert sketch.registry.get_point(target) is not None + + def test_typed_radius_ignores_the_snapped_point(self): + sketch = Sketch() + center = sketch.add_point(0, 0) + target = sketch.add_point(0, 10) + PolygonCommand( + sketch, center, (0.0, 10.0), end_pid=target, fixed_radius=4.0 + ).execute() + assert _lines(sketch)[0].p1_idx != target + assert _corner_positions(sketch)[0] == pytest.approx((0.0, 4.0)) + def test_rejects_fewer_than_three_sides(self): with pytest.raises(ValueError): _execute(sides=2) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/ui_gtk/tools/test_polygon_tool.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/ui_gtk/tools/test_polygon_tool.py index 872b3b375..34c530ce8 100644 --- a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/ui_gtk/tools/test_polygon_tool.py +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/ui_gtk/tools/test_polygon_tool.py @@ -73,6 +73,17 @@ def test_two_clicks_create_the_polygon(tool, element): assert len(_outline(element.sketch)) == 6 +@pytest.mark.ui +def test_first_corner_joins_a_snapped_point(tool, element, monkeypatch): + target = element.sketch.add_point(0.0, 8.0) + tool.on_press(0.0, 0.0, 1) + tool.on_release(0.5, 0.0) + tool.on_hover_motion(0.0, 8.0) + monkeypatch.setattr(tool, "get_snapped_point_id", lambda: target) + tool.on_press(0.0, 8.0, 1) + assert _outline(element.sketch)[0].p1_idx == target + + @pytest.mark.ui def test_ctrl_makes_a_star(tool, element): tool.on_press(0.0, 0.0, 1) From 56d4da48a466b5fe855247182ae85f2b7791aef8 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Thu, 15 Oct 2026 05:57:43 +0200 Subject: [PATCH 085/104] docs: translate the Polygon page Add the Polygon and Star page and the Polygon entries on the sketcher overview to every translated docs directory. Refs #522 --- .../current/features/sketcher/index.md | 13 +++--- .../current/features/sketcher/polygon.md | 39 ++++++++++++++++++ .../current/features/sketcher/index.md | 10 +++-- .../current/features/sketcher/polygon.md | 40 +++++++++++++++++++ .../current/features/sketcher/index.md | 14 ++++--- .../current/features/sketcher/polygon.md | 40 +++++++++++++++++++ .../current/features/sketcher/index.md | 16 ++++---- .../current/features/sketcher/polygon.md | 36 +++++++++++++++++ .../current/features/sketcher/index.md | 13 +++--- .../current/features/sketcher/polygon.md | 39 ++++++++++++++++++ .../current/features/sketcher/index.md | 5 ++- .../current/features/sketcher/polygon.md | 38 ++++++++++++++++++ .../current/features/sketcher/index.md | 3 +- .../current/features/sketcher/polygon.md | 25 ++++++++++++ 14 files changed, 299 insertions(+), 32 deletions(-) create mode 100644 website/i18n/de/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md create mode 100644 website/i18n/es/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md create mode 100644 website/i18n/fr/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md create mode 100644 website/i18n/hi/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md create mode 100644 website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md create mode 100644 website/i18n/uk/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md create mode 100644 website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/features/sketcher/index.md b/website/i18n/de/docusaurus-plugin-content-docs/current/features/sketcher/index.md index 8788054df..5c08d2ca9 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/features/sketcher/index.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/features/sketcher/index.md @@ -103,6 +103,7 @@ seiner Seite dokumentiert ist: | [Ellipse](arc-ellipse.md) | `G+C` | | [Rechteck](rectangle.md) | `G+R` | | [Abgerundetes Rechteck](rectangle.md) | `G+O` | +| [Polygon](polygon.md) | `G+X` | | [Bereiche füllen](fill.md) | `G+F` | | [Textfeld](text-box.md) | `G+T` | | [Kreisförmiges Array](arrays.md) | `G+Y` | @@ -155,9 +156,9 @@ im Weg sind. Die Zeichenwerkzeuge sind jeweils auf ihrer eigenen Seite dokumentiert: [Pfad](path.md) (Linien und Bezier-Kurven), [Bogen und Ellipse](arc-ellipse.md), [Rechteck](rectangle.md) (und abgerundete -Rechtecke), [Bereiche füllen](fill.md), [Textfeld](text-box.md) und [Raster](grid.md). -Modifikationen wie [Versatz](offset.md) und [Fase und Verrundung](chamfer-fillet.md) formen -bestehende Geometrie um, [Arrays](arrays.md) kopiert sie entlang eines Kreises oder einer Kurve, und -[Ausdrücke](expressions.md) erklärt Parameter, Ausdrücke und parametrische Textfelder. Skizzen -können mit allen Einschränkungen intakt gespeichert und erneut importiert werden — siehe -[Import und Export](import-export.md). +Rechtecke), [Polygon](polygon.md) (und Sterne), [Bereiche füllen](fill.md), [Textfeld](text-box.md) +und [Raster](grid.md). Modifikationen wie [Versatz](offset.md) und +[Fase und Verrundung](chamfer-fillet.md) formen bestehende Geometrie um, [Arrays](arrays.md) kopiert +sie entlang eines Kreises oder einer Kurve, und [Ausdrücke](expressions.md) erklärt Parameter, +Ausdrücke und parametrische Textfelder. Skizzen können mit allen Einschränkungen intakt gespeichert +und erneut importiert werden — siehe [Import und Export](import-export.md). diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md b/website/i18n/de/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md new file mode 100644 index 000000000..982a0db62 --- /dev/null +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md @@ -0,0 +1,39 @@ +--- +description: + "Zeichne regelmäßige Polygone und Sterne im Rayforge-Sketcher. Sie bestehen aus gewöhnlichen + Punkten und Linien, die von Einschränkungen regelmäßig gehalten werden." +--- + +# Polygon und Stern + +Das **Polygon**-Werkzeug (`G+X`) zeichnet ein regelmäßiges Polygon, etwa ein Sechseck für einen +Untersetzer, oder einen Stern. + +## Ein Polygon zeichnen + +Klicke dorthin, wo der Mittelpunkt liegen soll, bewege den Zeiger und klicke erneut, oder drücke am +Mittelpunkt, ziehe und lasse los. Der Zeiger legt die erste Ecke fest und bestimmt damit sowohl die +Größe als auch die Drehung. Ein neues Polygon hat 6 Ecken. + +- Halte `Ctrl` gedrückt, um stattdessen einen Stern zu zeichnen: Zwischen je zwei äußeren Ecken wird + eine innere Ecke auf dem halben äußeren Radius eingefügt. + +Während die Vorschau aktiv ist, kannst du exakte Werte eingeben: Die Statusleiste zeigt die Felder +`R`, `N` und `Star`. `R` ist der Radius vom Mittelpunkt zu den Ecken, `N` die Anzahl der Ecken (3 +bis 100) und `Star` der innere Radius als Bruchteil des äußeren (zwischen 0 und 1). Die Eingabe +eines `Star`-Werts macht aus dem Polygon einen Stern. Gib einen Wert ein, drücke `Tab`, um zum +nächsten Feld zu wechseln, und `Enter`, um die Form zu erstellen; `Esc` bricht die Vorschau ab. Das +Werkzeug merkt sich die Anzahl der Ecken für das nächste Polygon. + +## Was du erhältst + +Ein Polygon ist kein spezielles Objekt: Es besteht aus gewöhnlichen Punkten und Linien, sodass +Verrundung, Fase, Versatz und boolesche Operationen damit wie mit jeder anderen Form funktionieren. + +Einschränkungen halten es regelmäßig. Alle Kanten sind gleich lang, und die Ecken liegen auf einem +Konstruktionskreis um den Mittelpunkt; ein Stern hat einen zweiten Konstruktionskreis für seine +inneren Ecken und behält das Verhältnis zwischen den beiden Radien bei. Wenn du einen Radius +eingegeben hast, legt eine Radius-Einschränkung ihn fest. Du kannst dann den Mittelpunkt ziehen, um +das Polygon zu verschieben, oder eine Ecke ziehen, um es zu skalieren und zu drehen, und es bleibt +regelmäßig. Um die Größe später zu ändern, bearbeite die Radius-Einschränkung oder füge dem +Konstruktionskreis eine hinzu. diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/features/sketcher/index.md b/website/i18n/es/docusaurus-plugin-content-docs/current/features/sketcher/index.md index bc7c8feec..11978e4bf 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/features/sketcher/index.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/features/sketcher/index.md @@ -101,6 +101,7 @@ página: | [Elipse](arc-ellipse.md) | `G+C` | | [Rectángulo](rectangle.md) | `G+R` | | [Rectángulo redondeado](rectangle.md) | `G+O` | +| [Polígono](polygon.md) | `G+X` | | [Rellenar área](fill.md) | `G+F` | | [Cuadro de texto](text-box.md) | `G+T` | | [Arreglo Circular](arrays.md) | `G+Y` | @@ -152,9 +153,10 @@ estorban. Las herramientas de dibujo están documentadas cada una en su propia página: [Trazado](path.md) (líneas y curvas Bézier), [Arco y elipse](arc-ellipse.md), [Rectángulo](rectangle.md) (y rectángulos -redondeados), [Rellenar áreas](fill.md), [Cuadro de texto](text-box.md) y [Cuadrícula](grid.md). Las -modificaciones como [Desplazamiento](offset.md) y [Chaflán y redondeo](chamfer-fillet.md) dan nueva -forma a la geometría existente, [Arreglos](arrays.md) la copia a lo largo de un círculo o una curva, -y [Expresiones](expressions.md) explica los parámetros, las expresiones y los cuadros de texto +redondeados), [Polígono](polygon.md) (y estrellas), [Rellenar áreas](fill.md), +[Cuadro de texto](text-box.md) y [Cuadrícula](grid.md). Las modificaciones como +[Desplazamiento](offset.md) y [Chaflán y redondeo](chamfer-fillet.md) dan nueva forma a la geometría +existente, [Arreglos](arrays.md) la copia a lo largo de un círculo o una curva, y +[Expresiones](expressions.md) explica los parámetros, las expresiones y los cuadros de texto paramétricos. Los bocetos se pueden guardar y volver a importar con todas las restricciones intactas; consulte [Importación y exportación](import-export.md). diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md b/website/i18n/es/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md new file mode 100644 index 000000000..5124df27f --- /dev/null +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md @@ -0,0 +1,40 @@ +--- +description: + "Dibuje polígonos regulares y estrellas en el diseñador de Rayforge. Están formados por puntos y + líneas normales que las restricciones mantienen regulares." +--- + +# Polígono y estrella + +La herramienta **polígono** (`G+X`) dibuja un polígono regular, como un hexágono para un posavasos, +o una estrella. + +## Dibujar un polígono + +Haga clic donde debe estar el centro, mueva el puntero y vuelva a hacer clic, o pulse en el centro, +arrastre y suelte. El puntero fija la primera esquina, por lo que determina tanto el tamaño como la +rotación. Un polígono nuevo tiene 6 esquinas. + +- Mantenga `Ctrl` para dibujar una estrella en su lugar: añade una esquina interior entre cada dos + esquinas exteriores, a la mitad del radio exterior. + +Mientras la vista previa está activa, puede introducir valores exactos: la barra de estado muestra +los campos `R`, `N` y `Star`. `R` es el radio desde el centro hasta las esquinas, `N` el número de +esquinas (de 3 a 100) y `Star` el radio interior como fracción del exterior (entre 0 y 1). +Introducir un valor en `Star` convierte el polígono en una estrella. Escriba un valor, pulse `Tab` +para pasar al siguiente campo y `Enter` para crear la forma; `Esc` cancela la vista previa. La +herramienta recuerda el número de esquinas para el siguiente polígono. + +## Qué obtiene + +Un polígono no es un objeto especial: consta de puntos y líneas normales, por lo que el redondeo, el +chaflán, el desplazamiento y las operaciones booleanas funcionan con él como con cualquier otra +forma. + +Las restricciones lo mantienen regular. Todos los lados tienen la misma longitud y las esquinas +están sobre un círculo de construcción alrededor del punto central; una estrella tiene un segundo +círculo de construcción para sus esquinas interiores y mantiene la proporción entre ambos radios. Si +introdujo un radio, una restricción de radio lo fija. Después puede arrastrar el centro para mover +el polígono, o arrastrar una esquina para escalarlo y girarlo, y seguirá siendo regular. Para +cambiar el tamaño más adelante, edite la restricción de radio o añada una al círculo de +construcción. diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/sketcher/index.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/sketcher/index.md index 2155f50bc..345f2a6c2 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/sketcher/index.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/sketcher/index.md @@ -103,6 +103,7 @@ sa page : | [Ellipse](arc-ellipse.md) | `G+C` | | [Rectangle](rectangle.md) | `G+R` | | [Rectangle arrondi](rectangle.md) | `G+O` | +| [Polygone](polygon.md) | `G+X` | | [Remplissage de zone](fill.md) | `G+F` | | [Zone de texte](text-box.md) | `G+T` | | [Tableau circulaire](arrays.md) | `G+Y` | @@ -154,9 +155,10 @@ de construction dans la barre d'outils les masque quand elles gênent. Chaque outil de dessin est documenté sur sa propre page : [Tracé](path.md) (lignes et courbes de Bézier), [Arc et ellipse](arc-ellipse.md), [Rectangle](rectangle.md) (et rectangles arrondis), -[Remplissage de zones](fill.md), [Zone de texte](text-box.md) et [Grille](grid.md). Les -modifications comme [Décalage](offset.md) et [Chanfrein et congé](chamfer-fillet.md) transforment la -géométrie existante, [Tableaux](arrays.md) la copie le long d'un cercle ou d'une courbe, et -[Expressions](expressions.md) explique les paramètres, les expressions et les zones de texte -paramétriques. Les esquisses peuvent être enregistrées puis réimportées avec toutes leurs -contraintes — voir [Importation et exportation](import-export.md). +[Polygone](polygon.md) (et étoiles), [Remplissage de zones](fill.md), [Zone de texte](text-box.md) +et [Grille](grid.md). Les modifications comme [Décalage](offset.md) et +[Chanfrein et congé](chamfer-fillet.md) transforment la géométrie existante, [Tableaux](arrays.md) +la copie le long d'un cercle ou d'une courbe, et [Expressions](expressions.md) explique les +paramètres, les expressions et les zones de texte paramétriques. Les esquisses peuvent être +enregistrées puis réimportées avec toutes leurs contraintes — voir +[Importation et exportation](import-export.md). diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md new file mode 100644 index 000000000..b0246dae8 --- /dev/null +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md @@ -0,0 +1,40 @@ +--- +description: + "Dessinez des polygones réguliers et des étoiles dans l'esquisseur Rayforge. Ils sont faits de + points et de lignes ordinaires, maintenus réguliers par des contraintes." +--- + +# Polygone et étoile + +L'outil **polygone** (`G+X`) dessine un polygone régulier, par exemple un hexagone pour un +sous-verre, ou une étoile. + +## Dessiner un polygone + +Cliquez à l'endroit du centre, déplacez le pointeur et cliquez à nouveau, ou appuyez au centre, +faites glisser et relâchez. Le pointeur fixe le premier sommet ; il détermine donc à la fois la +taille et la rotation. Un nouveau polygone a 6 sommets. + +- Maintenez `Ctrl` pour dessiner une étoile à la place : un sommet intérieur est ajouté entre chaque + paire de sommets extérieurs, à la moitié du rayon extérieur. + +Pendant que l'aperçu est actif, vous pouvez saisir des valeurs exactes : la barre d'état affiche les +champs `R`, `N` et `Star`. `R` est le rayon du centre aux sommets, `N` le nombre de sommets (de 3 +à 100) et `Star` le rayon intérieur en fraction du rayon extérieur (entre 0 et 1). Saisir une valeur +`Star` transforme le polygone en étoile. Tapez une valeur, appuyez sur `Tab` pour passer au champ +suivant et sur `Enter` pour créer la forme ; `Esc` annule l'aperçu. L'outil retient le nombre de +sommets pour le polygone suivant. + +## Ce que vous obtenez + +Un polygone n'est pas un objet spécial : il se compose de points et de lignes ordinaires, de sorte +que le congé, le chanfrein, le décalage et les opérations booléennes s'y appliquent comme à +n'importe quelle autre forme. + +Des contraintes le maintiennent régulier. Tous les côtés ont la même longueur et les sommets se +trouvent sur un cercle de construction autour du point central ; une étoile possède un second cercle +de construction pour ses sommets intérieurs et conserve le rapport entre les deux rayons. Si vous +avez saisi un rayon, une contrainte de rayon le fixe. Vous pouvez ensuite faire glisser le centre +pour déplacer le polygone, ou faire glisser un sommet pour le redimensionner et le faire pivoter : +il reste régulier. Pour modifier la taille plus tard, modifiez la contrainte de rayon ou ajoutez-en +une au cercle de construction. diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/sketcher/index.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/sketcher/index.md index e8cba29ec..1104d2d7e 100644 --- a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/sketcher/index.md +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/sketcher/index.md @@ -90,6 +90,7 @@ Rayforge में एप्लिकेशन में सीधे पार | [दीर्घवृत्त](arc-ellipse.md) | `G+C` | | [आयत](rectangle.md) | `G+R` | | [गोलाकार आयत](rectangle.md) | `G+O` | +| [बहुभुज](polygon.md) | `G+X` | | [क्षेत्र भरें](fill.md) | `G+F` | | [टेक्स्ट बॉक्स](text-box.md) | `G+T` | | [वृत्तीय ऐरे](arrays.md) | `G+Y` | @@ -138,10 +139,11 @@ Rayforge में एप्लिकेशन में सीधे पार ## आगे कहाँ जाएँ ड्रॉइंग उपकरण प्रत्येक अपने पृष्ठ पर वर्णित हैं: [पाथ](path.md) (रेखाएँ और बेज़ियर वक्र), -[चाप और दीर्घवृत्त](arc-ellipse.md), [आयत](rectangle.md) (और गोलाकार आयत), [क्षेत्र भरें](fill.md), -[टेक्स्ट बॉक्स](text-box.md), और [ग्रिड](grid.md). [ऑफ़सेट](offset.md) और -[छरछरी और फ़िलेट](chamfer-fillet.md) जैसे संशोधन मौजूदा ज्यामिति को नया रूप देते हैं, -[ऐरे](arrays.md) उसे वृत्त या वक्र पर प्रतिलिपि बनाता है, और [पैरामीटर और व्यंजक](expressions.md) -समझाता है कि पैरामीटर कैसे जोड़ें और संपादित करें, ज्यामिति और टेक्स्ट बॉक्स को उनसे संचालित करें, -और प्रति स्केच इंस्टेंस मान निर्दिष्ट करें. स्केच सभी कॉन्स्ट्रेंट सुरक्षित रखते हुए सहेजे और पुनः -आयात किए जा सकते हैं — [आयात और निर्यात](import-export.md) देखें. +[चाप और दीर्घवृत्त](arc-ellipse.md), [आयत](rectangle.md) (और गोलाकार आयत), [बहुभुज](polygon.md) (और +तारे), [क्षेत्र भरें](fill.md), [टेक्स्ट बॉक्स](text-box.md), और [ग्रिड](grid.md). +[ऑफ़सेट](offset.md) और [छरछरी और फ़िलेट](chamfer-fillet.md) जैसे संशोधन मौजूदा ज्यामिति को नया रूप +देते हैं, [ऐरे](arrays.md) उसे वृत्त या वक्र पर प्रतिलिपि बनाता है, और +[पैरामीटर और व्यंजक](expressions.md) समझाता है कि पैरामीटर कैसे जोड़ें और संपादित करें, ज्यामिति और +टेक्स्ट बॉक्स को उनसे संचालित करें, और प्रति स्केच इंस्टेंस मान निर्दिष्ट करें. स्केच सभी +कॉन्स्ट्रेंट सुरक्षित रखते हुए सहेजे और पुनः आयात किए जा सकते हैं — +[आयात और निर्यात](import-export.md) देखें. diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md new file mode 100644 index 000000000..6b0e678c8 --- /dev/null +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md @@ -0,0 +1,36 @@ +--- +description: + "Rayforge स्केचर में नियमित बहुभुज और तारे बनाएँ. वे साधारण बिंदुओं और रेखाओं से बने होते हैं, + जिन्हें कॉन्स्ट्रेंट नियमित बनाए रखते हैं." +--- + +# बहुभुज और तारा + +**बहुभुज** उपकरण (`G+X`) एक नियमित बहुभुज बनाता है, जैसे कोस्टर के लिए षट्भुज, या एक तारा. + +## बहुभुज बनाना + +जहाँ केंद्र होना चाहिए वहाँ क्लिक करें, पॉइंटर को घुमाएँ और फिर से क्लिक करें, या केंद्र पर दबाएँ, +खींचें और छोड़ें. पॉइंटर पहला कोना तय करता है, इसलिए वह आकार और घुमाव दोनों तय करता है. एक नए बहुभुज +में 6 कोने होते हैं. + +- इसके बजाय तारा बनाने के लिए `Ctrl` दबाए रखें: यह हर दो बाहरी कोनों के बीच, बाहरी त्रिज्या के आधे + पर, एक भीतरी कोना जोड़ता है. + +जब पूर्वावलोकन सक्रिय हो, तब आप सटीक मान टाइप कर सकते हैं: स्टेटस बार `R`, `N` और `Star` फ़ील्ड +दिखाता है. `R` केंद्र से कोनों तक की त्रिज्या है, `N` कोनों की संख्या (3 से 100), और `Star` बाहरी +त्रिज्या के अंश के रूप में भीतरी त्रिज्या (0 और 1 के बीच). `Star` मान टाइप करने से बहुभुज तारे में +बदल जाता है. एक मान टाइप करें, अगले फ़ील्ड पर जाने के लिए `Tab` दबाएँ, और आकृति बनाने के लिए +`Enter`; `Esc` पूर्वावलोकन रद्द करता है. उपकरण अगले बहुभुज के लिए कोनों की संख्या याद रखता है. + +## आपको क्या मिलता है + +बहुभुज कोई विशेष ऑब्जेक्ट नहीं है: यह साधारण बिंदुओं और रेखाओं से बना होता है, इसलिए फ़िलेट, छरछरी, +ऑफ़सेट और बूलियन ऑपरेशन इस पर किसी भी अन्य आकृति की तरह काम करते हैं. + +कॉन्स्ट्रेंट इसे नियमित बनाए रखते हैं. सभी किनारों की लंबाई बराबर होती है, और कोने केंद्र बिंदु के +चारों ओर एक निर्माण वृत्त पर होते हैं; तारे में उसके भीतरी कोनों के लिए दूसरा निर्माण वृत्त होता है +और वह दोनों त्रिज्याओं का अनुपात बनाए रखता है. यदि आपने त्रिज्या टाइप की थी, तो एक त्रिज्या +कॉन्स्ट्रेंट उसे स्थिर करता है. इसके बाद आप बहुभुज को खिसकाने के लिए केंद्र को खींच सकते हैं, या +उसका आकार बदलने और उसे घुमाने के लिए किसी कोने को खींच सकते हैं, और वह नियमित बना रहता है. बाद में +आकार बदलने के लिए, त्रिज्या कॉन्स्ट्रेंट संपादित करें या निर्माण वृत्त में एक जोड़ें. diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/sketcher/index.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/sketcher/index.md index 6f57a857e..620c5757f 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/sketcher/index.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/sketcher/index.md @@ -99,6 +99,7 @@ sua página: | [Elipse](arc-ellipse.md) | `G+C` | | [Retângulo](rectangle.md) | `G+R` | | [Retângulo arredondado](rectangle.md) | `G+O` | +| [Polígono](polygon.md) | `G+X` | | [Preenchimento de áreas](fill.md) | `G+F` | | [Caixa de texto](text-box.md) | `G+T` | | [Matriz Circular](arrays.md) | `G+Y` | @@ -148,9 +149,9 @@ sinalizador; o alternador de construção na barra de ferramentas as oculta quan As ferramentas de desenho estão documentadas em páginas próprias: [Caminho](path.md) (linhas e curvas de Bézier), [Arco e Elipse](arc-ellipse.md), [Retângulo](rectangle.md) (e retângulos -arredondados), [Preenchimento de áreas](fill.md), [Caixa de texto](text-box.md) e [Grade](grid.md). -Modificações como [Deslocamento](offset.md) e [Chanfro e arredondamento](chamfer-fillet.md) -remodelam a geometria existente, [Matrizes](arrays.md) copiam a geometria ao longo de um círculo ou -de uma curva, e [Expressões](expressions.md) explica parâmetros, expressões e caixas de texto -paramétricas. Esboços podem ser salvos e reimportados com todas as restrições intactas — veja -[Importação e exportação](import-export.md). +arredondados), [Polígono](polygon.md) (e estrelas), [Preenchimento de áreas](fill.md), +[Caixa de texto](text-box.md) e [Grade](grid.md). Modificações como [Deslocamento](offset.md) e +[Chanfro e arredondamento](chamfer-fillet.md) remodelam a geometria existente, [Matrizes](arrays.md) +copiam a geometria ao longo de um círculo ou de uma curva, e [Expressões](expressions.md) explica +parâmetros, expressões e caixas de texto paramétricas. Esboços podem ser salvos e reimportados com +todas as restrições intactas — veja [Importação e exportação](import-export.md). diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md new file mode 100644 index 000000000..69d39d25a --- /dev/null +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md @@ -0,0 +1,39 @@ +--- +description: + "Desenhe polígonos regulares e estrelas no esboçador do Rayforge. Eles são feitos de pontos e + linhas comuns, mantidos regulares por restrições." +--- + +# Polígono e estrela + +A ferramenta de **polígono** (`G+X`) desenha um polígono regular, como um hexágono para um +porta-copos, ou uma estrela. + +## Desenhar um polígono + +Clique onde o centro deve ficar, mova o ponteiro e clique novamente, ou pressione no centro, arraste +e solte. O ponteiro define o primeiro vértice, portanto define tanto o tamanho quanto a rotação. Um +novo polígono tem 6 vértices. + +- Segure `Ctrl` para desenhar uma estrela: um vértice interno é adicionado entre cada dois vértices + externos, na metade do raio externo. + +Enquanto a pré-visualização estiver ativa, você pode digitar valores exatos: a barra de status +mostra os campos `R`, `N` e `Star`. `R` é o raio do centro até os vértices, `N` o número de vértices +(de 3 a 100) e `Star` o raio interno como fração do externo (entre 0 e 1). Digitar um valor em +`Star` transforma o polígono em estrela. Digite um valor, pressione `Tab` para ir ao próximo campo e +`Enter` para criar a forma; `Esc` cancela a pré-visualização. A ferramenta lembra o número de +vértices para o próximo polígono. + +## O que você obtém + +Um polígono não é um objeto especial: ele consiste em pontos e linhas comuns, de modo que +arredondamento, chanfro, deslocamento e operações booleanas funcionam nele como em qualquer outra +forma. + +Restrições o mantêm regular. Todas as arestas têm o mesmo comprimento e os vértices ficam sobre um +círculo de construção em torno do ponto central; uma estrela tem um segundo círculo de construção +para seus vértices internos e mantém a proporção entre os dois raios. Se você digitou um raio, uma +restrição de raio o fixa. Depois você pode arrastar o centro para mover o polígono, ou arrastar um +vértice para redimensioná-lo e girá-lo, e ele continua regular. Para alterar o tamanho depois, edite +a restrição de raio ou adicione uma ao círculo de construção. diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/sketcher/index.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/sketcher/index.md index 82dc9ec22..3dcb1126b 100644 --- a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/sketcher/index.md +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/sketcher/index.md @@ -95,6 +95,7 @@ description: | [Еліпс](arc-ellipse.md) | `G+C` | | [Прямокутник](rectangle.md) | `G+R` | | [Прямокутник із заокругленими кутами](rectangle.md) | `G+O` | +| [Багатокутник](polygon.md) | `G+X` | | [Заливка області](fill.md) | `G+F` | | [Текстовий блок](text-box.md) | `G+T` | | [Круговий масив](arrays.md) | `G+Y` | @@ -144,8 +145,8 @@ description: Кожен інструмент малювання задокументовано на власній сторінці: [Шлях](path.md) (лінії та криві Безьє), [Дуга та еліпс](arc-ellipse.md), [Прямокутник](rectangle.md) (та прямокутники із -заокругленими кутами), [Заливка областей](fill.md), [Текстовий блок](text-box.md) і -[Сітка](grid.md). Модифікації на кшталт [Зміщення](offset.md) та +заокругленими кутами), [Багатокутник](polygon.md) (та зірки), [Заливка областей](fill.md), +[Текстовий блок](text-box.md) і [Сітка](grid.md). Модифікації на кшталт [Зміщення](offset.md) та [Фаски й скруглення](chamfer-fillet.md) змінюють наявну геометрію, [Масиви](arrays.md) копіюють її по колу або кривій, а [Вирази](expressions.md) пояснює параметри, вирази та параметричні текстові блоки. Ескізи можна зберігати та повторно імпортувати з усіма обмеженнями — див. diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md new file mode 100644 index 000000000..0b3c8d331 --- /dev/null +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md @@ -0,0 +1,38 @@ +--- +description: + "Малюйте правильні багатокутники та зірки в ескізері Rayforge. Вони складаються зі звичайних точок + і ліній, а правильну форму їм зберігають обмеження." +--- + +# Багатокутник і зірка + +Інструмент **багатокутника** (`G+X`) малює правильний багатокутник, наприклад шестикутник для +підставки, або зірку. + +## Малювання багатокутника + +Клацніть там, де має бути центр, перемістіть вказівник і клацніть ще раз, або натисніть у центрі, +перетягніть і відпустіть. Вказівник задає першу вершину, тож він визначає і розмір, і поворот. Новий +багатокутник має 6 вершин. + +- Утримуйте `Ctrl`, щоб натомість намалювати зірку: між кожними двома зовнішніми вершинами додається + внутрішня вершина на половині зовнішнього радіуса. + +Поки активний попередній перегляд, можна ввести точні значення: рядок стану показує поля `R`, `N` і +`Star`. `R` — це радіус від центру до вершин, `N` — кількість вершин (від 3 до 100), а `Star` — +внутрішній радіус як частка зовнішнього (від 0 до 1). Введення значення `Star` перетворює +багатокутник на зірку. Введіть значення, натисніть `Tab`, щоб перейти до наступного поля, і `Enter`, +щоб створити фігуру; `Esc` скасовує попередній перегляд. Інструмент запам'ятовує кількість вершин +для наступного багатокутника. + +## Що ви отримуєте + +Багатокутник не є особливим об'єктом: він складається зі звичайних точок і ліній, тож скруглення, +фаска, зміщення та булеві операції працюють із ним так само, як з будь-якою іншою фігурою. + +Обмеження зберігають його правильним. Усі сторони мають однакову довжину, а вершини лежать на +допоміжному колі навколо центральної точки; зірка має друге допоміжне коло для внутрішніх вершин і +зберігає співвідношення між двома радіусами. Якщо ви ввели радіус, його фіксує обмеження радіуса. +Потім можна перетягнути центр, щоб перемістити багатокутник, або перетягнути вершину, щоб змінити +його розмір і повернути, і він залишиться правильним. Щоб пізніше змінити розмір, відредагуйте +обмеження радіуса або додайте його до допоміжного кола. diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/sketcher/index.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/sketcher/index.md index 163b351bf..efc1b92bd 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/sketcher/index.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/sketcher/index.md @@ -66,6 +66,7 @@ mm,整个零件就会随之更新。尺寸约束还接受表达式——半径 | [椭圆](arc-ellipse.md) | `G+C` | | [矩形](rectangle.md) | `G+R` | | [圆角矩形](rectangle.md) | `G+O` | +| [多边形](polygon.md) | `G+X` | | [填充区域](fill.md) | `G+F` | | [文本框](text-box.md) | `G+T` | | [圆形阵列](arrays.md) | `G+Y` | @@ -103,6 +104,6 @@ mm,整个零件就会随之更新。尺寸约束还接受表达式——半径 ## 后续步骤 每个绘图工具都在各自的页面上有文档说明:[路径](path.md)(直线和贝塞尔曲线)、[弧与椭圆](arc-ellipse.md)、[矩形](rectangle.md) -(以及圆角矩形)、[填充区域](fill.md)、[文本框](text-box.md)和[网格](grid.md)。诸如 +(以及圆角矩形)、[多边形](polygon.md)(以及星形)、[填充区域](fill.md)、[文本框](text-box.md)和[网格](grid.md)。诸如 [偏移](offset.md)和[倒角与圆角](chamfer-fillet.md)之类的修改操作可以重塑现有几何图形,[阵列](arrays.md)沿圆或曲线复制它, [表达式](expressions.md)则解释参数、表达式和参数化文本框。草图可以保存并重新导入且保留所有约束——参见[导入和导出](import-export.md)。 diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md new file mode 100644 index 000000000..aa813d6d5 --- /dev/null +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md @@ -0,0 +1,25 @@ +--- +description: + "在 Rayforge 草图绘制器中绘制正多边形和星形。它们由普通的点和线组成,并由约束保持规则。" +--- + +# 多边形与星形 + +**多边形**工具(`G+X`)可绘制正多边形(例如用作杯垫的六边形)或星形。 + +## 绘制多边形 + +在中心所在位置单击,移动指针后再次单击;或在中心按下、拖动并释放。指针确定第一个角,因此同时决定大小和旋转。新多边形有 6 个角。 + +- 按住 `Ctrl` 可改为绘制星形:在每两个外角之间添加一个内角,位于外半径的一半处。 + +预览处于活动状态时,你可以输入精确数值:状态栏会显示 `R`、`N` 和 `Star` 字段。`R` +是从中心到各角的半径,`N` 是角的数量(3 到 100),`Star` 是内半径占外半径的比例(0 到 1 之间)。输入 +`Star` 值会把多边形变成星形。输入数值,按 `Tab` 切换到下一个字段,按 `Enter` 创建形状;`Esc` +可取消预览。该工具会为下一个多边形记住角的数量。 + +## 得到的结果 + +多边形不是特殊对象:它由普通的点和线组成,因此圆角、倒角、偏移和布尔运算对它的作用与对其他任何形状相同。 + +约束使其保持规则。所有边长度相等,各角位于围绕中心点的构造圆上;星形还有第二个构造圆用于内角,并保持两个半径之间的比例。如果你输入了半径,半径约束会将其固定。之后你可以拖动中心来移动多边形,或拖动某个角来缩放和旋转它,它始终保持规则。之后要更改大小,请编辑半径约束,或为构造圆添加一个半径约束。 From 5892810cbb9e6ea53e92ed1ca0cf77419887ee33 Mon Sep 17 00:00:00 2001 From: Samuel Abels Date: Thu, 15 Oct 2026 12:46:39 +0200 Subject: [PATCH 086/104] refactor(step): replace generated_workpiece_uid with a workpiece filter A step's workpiece selection becomes a generic workpiece_uids list (empty = all workpieces of the layer) instead of the pipeline knowing about the generated-workpiece concept: the intent builder now just asks the step which workpieces it applies to. Projects saved with the old single-uid key still load, and new projects still write it so older versions keep rendering generated content correctly. The step settings dialog gains a read-only Workpieces tab showing which workpieces a step processes on its layer. --- CHANGELOG.md | 5 + .../commands/material_test_cmd.py | 6 +- .../laser_essentials/steps/laser_step.py | 1 - rayforge/core/step.py | 43 ++++++- rayforge/pipeline/intent_builder.py | 21 +--- .../ui_gtk/doceditor/step_settings/dialog.py | 27 ++++ .../doceditor/step_settings/pages/__init__.py | 2 + .../step_settings/pages/workpieces.py | 115 ++++++++++++++++++ tests/core/test_step.py | 98 +++++++++++++-- tests/pipeline/test_intent_builder.py | 14 +-- .../step_settings/test_workpieces_page.py | 108 ++++++++++++++++ 11 files changed, 399 insertions(+), 41 deletions(-) create mode 100644 rayforge/ui_gtk/doceditor/step_settings/pages/workpieces.py create mode 100644 tests/ui_gtk/doceditor/step_settings/test_workpieces_page.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5933b5675..ade2d2fb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 editing, and Apply is a single undo step (#515) - Addons: the `register_image_edits` hook and image edit registry (plugin API 23) let addons provide their own image edits +- Step settings: a new Workpieces tab shows which workpieces a step + processes on its layer. A step normally processes every workpiece of + its layer; generated content like the Material Test Grid restricts + its step to the workpiece it creates, and that selection is now + visible instead of hidden ### Changed 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 index c16fb6903..1b446178e 100644 --- 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 @@ -124,7 +124,7 @@ def create_test_grid(self): assert isinstance(workpiece, WorkPiece) self._doc.add_asset(source) - step.generated_workpiece_uid = workpiece.uid # Link step to WP + step.workpiece_uids = [workpiece.uid] # Link step to WP width_mm, height_mm = workpiece.size machine_dims = self._editor.machine_dimensions @@ -167,10 +167,10 @@ def _on_step_updated( self.sync_preview_from_step(origin) def sync_preview_from_step(self, step: Step): - if not step.generated_workpiece_uid: + if not step.workpiece_uids: return - item = self._doc.find_descendant_by_uid(step.generated_workpiece_uid) + 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 diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/steps/laser_step.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/steps/laser_step.py index fbd3520d7..69d0b8ca9 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/steps/laser_step.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/steps/laser_step.py @@ -197,7 +197,6 @@ def get_settings(self) -> dict[str, Any]: "tab_power": self.tab_power, "frequency": self.frequency, "pulse_width": self.pulse_width, - "generated_workpiece_uid": self.generated_workpiece_uid, } def apply_import_settings(self, settings: dict[str, Any]) -> None: diff --git a/rayforge/core/step.py b/rayforge/core/step.py index ab5075bd1..8c98145e7 100644 --- a/rayforge/core/step.py +++ b/rayforge/core/step.py @@ -1,5 +1,6 @@ import logging from abc import ABC +from collections.abc import Sequence from gettext import gettext as _ from typing import ( TYPE_CHECKING, @@ -94,7 +95,7 @@ def __init__( self.typelabel = typelabel self.visible = True self.selected_head_uid: str | None = None - self.generated_workpiece_uid: str | None = None + self.workpiece_uids: list[str] = [] self.applied_recipe_uid: str | None = None per_wp_defaults, per_sp_defaults = ( @@ -572,7 +573,12 @@ def to_dict(self) -> dict: "typelabel": self.typelabel, "visible": self.visible, "selected_head_uid": self.selected_head_uid, - "generated_workpiece_uid": self.generated_workpiece_uid, + "workpiece_uids": self.workpiece_uids, + "generated_workpiece_uid": ( + self.workpiece_uids[0] + if len(self.workpiece_uids) == 1 + else None + ), "applied_recipe_uid": self.applied_recipe_uid, "per_workpiece_transformers_dicts": ( self.per_workpiece_transformers_dicts @@ -609,6 +615,7 @@ def _serialized_keys(cls) -> frozenset[str]: "visible", "selected_laser_uid", "selected_head_uid", + "workpiece_uids", "generated_workpiece_uid", "applied_recipe_uid", "modifiers_dicts", @@ -675,7 +682,11 @@ def from_dict(cls, data: dict[str, Any]) -> "Step": step.selected_head_uid = data.get( "selected_head_uid", data.get("selected_laser_uid") ) - step.generated_workpiece_uid = data.get("generated_workpiece_uid") + step.workpiece_uids = list(data.get("workpiece_uids") or []) + if not step.workpiece_uids: + legacy = data.get("generated_workpiece_uid") + if legacy: + step.workpiece_uids = [legacy] step.applied_recipe_uid = data.get("applied_recipe_uid") default_per_wp, default_per_step = ( @@ -810,6 +821,32 @@ def set_selected_head_uid(self, uid: str | None): self.selected_head_uid = uid self.updated.send(self) + def selected_workpieces( + self, workpieces: Sequence["WorkPiece"] + ) -> list["WorkPiece"]: + """ + The given workpieces this step applies to, in input order. + + An empty ``workpiece_uids`` filter matches every workpiece; a + set filter matches only the workpieces whose uid it lists, and + silently skips uids that are not present. + """ + if not self.workpiece_uids: + return list(workpieces) + uids = set(self.workpiece_uids) + return [wp for wp in workpieces if wp.uid in uids] + + def set_workpiece_uids(self, uids: list[str]): + """ + Sets the workpieces this step applies to (empty means all of + the layer's workpieces) and notifies listeners of the change. + """ + normalized = list(dict.fromkeys(uids)) + if self.workpiece_uids == normalized: + return + self.workpiece_uids = normalized + self.updated.send(self) + def set_name(self, name: str): """Sets the step name and notifies listeners of the change.""" if self.name != name: diff --git a/rayforge/pipeline/intent_builder.py b/rayforge/pipeline/intent_builder.py index 63c6ff644..544be8d60 100644 --- a/rayforge/pipeline/intent_builder.py +++ b/rayforge/pipeline/intent_builder.py @@ -246,7 +246,7 @@ def build(self, doc: Doc) -> list[NodeRequest]: step, nodes ) continue - step_workpieces = self._workpieces_for_step(step, workpieces) + step_workpieces = step.selected_workpieces(workpieces) if not step_workpieces: continue inputs = self._build_workpiece_nodes( @@ -289,25 +289,6 @@ def build(self, doc: Doc) -> list[NodeRequest]: # Workpiece compute nodes # ------------------------------------------------------------------ - @staticmethod - def _workpieces_for_step( - step: Step, workpieces: Sequence[WorkPiece] - ) -> list[WorkPiece]: - """The workpieces a step applies to. - - A step that generated a specific workpiece (see - ``Step.generated_workpiece_uid``) only processes that workpiece; - every other step processes all workpieces in the layer. Returns - an empty list when the generated workpiece is not present. - """ - generated_uid = step.generated_workpiece_uid - if generated_uid is None: - return list(workpieces) - for wp in workpieces: - if wp.uid == generated_uid: - return [wp] - return [] - def _build_workpiece_nodes( self, step: Step, diff --git a/rayforge/ui_gtk/doceditor/step_settings/dialog.py b/rayforge/ui_gtk/doceditor/step_settings/dialog.py index faeb63251..a7769dc77 100644 --- a/rayforge/ui_gtk/doceditor/step_settings/dialog.py +++ b/rayforge/ui_gtk/doceditor/step_settings/dialog.py @@ -12,6 +12,7 @@ GeneralStepSettingsPage, PostProcessingPage, StepSettingsPage, + WorkpiecesPage, ) from rayforge.ui_gtk.icons import get_icon from rayforge.ui_gtk.shared.patched_dialog_window import PatchedDialogWindow @@ -101,6 +102,17 @@ def __init__( ) self.stack.add_named(scrolled_page2, "post-processing") + # --- Workpieces (read-only) --- + self.workpieces_view: WorkpiecesPage | None = None + if step.needs_workpieces and step.layer is not None: + self.workpieces_view = WorkpiecesPage(self.editor, self.step) + scrolled_page3 = Gtk.ScrolledWindow( + child=self.workpieces_view, + hscrollbar_policy=Gtk.PolicyType.NEVER, + vscrollbar_policy=Gtk.PolicyType.AUTOMATIC, + ) + self.stack.add_named(scrolled_page3, "workpieces") + # --- Build the custom switcher --- switcher_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) switcher_box.add_css_class("linked") @@ -145,6 +157,19 @@ def __init__( ) switcher_box.append(self.btn_post_processing) + self.btn_workpieces: Gtk.ToggleButton | None = None + if self.workpieces_view is not None: + self.btn_workpieces = Gtk.ToggleButton( + group=self.btn_step_settings + ) + self.btn_workpieces.set_child( + self._create_tab_title(_("Workpieces"), "layer-symbolic") + ) + self.btn_workpieces.connect( + "toggled", self._on_tab_toggled, self.stack, "workpieces" + ) + switcher_box.append(self.btn_workpieces) + has_post_processors = bool( step.per_step_transformers_dicts or step.per_workpiece_transformers_dicts @@ -229,6 +254,8 @@ def _on_close_request(self, window): + [page for _, page, _ in self._extra_pages] + [self.post_processing_view] ) + if self.workpieces_view is not None: + pages.append(self.workpieces_view) for page in pages: cleanup = getattr(page, "_cleanup", None) if callable(cleanup): diff --git a/rayforge/ui_gtk/doceditor/step_settings/pages/__init__.py b/rayforge/ui_gtk/doceditor/step_settings/pages/__init__.py index ee114efa2..871883d8e 100644 --- a/rayforge/ui_gtk/doceditor/step_settings/pages/__init__.py +++ b/rayforge/ui_gtk/doceditor/step_settings/pages/__init__.py @@ -3,9 +3,11 @@ from .base import StepSettingsPage from .general import GeneralStepSettingsPage from .post_processing import PostProcessingPage +from .workpieces import WorkpiecesPage __all__ = [ "GeneralStepSettingsPage", "PostProcessingPage", "StepSettingsPage", + "WorkpiecesPage", ] diff --git a/rayforge/ui_gtk/doceditor/step_settings/pages/workpieces.py b/rayforge/ui_gtk/doceditor/step_settings/pages/workpieces.py new file mode 100644 index 000000000..3bf85a993 --- /dev/null +++ b/rayforge/ui_gtk/doceditor/step_settings/pages/workpieces.py @@ -0,0 +1,115 @@ +"""Read-only view of the workpieces a step processes.""" + +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +from gi.repository import Adw, Gtk + +from ....icons import get_icon +from .base import StepSettingsPage + +if TYPE_CHECKING: + from .....doceditor.editor import DocEditor + + +class WorkpiecesPage(StepSettingsPage): + """Lists the workpieces a step processes on its layer. + + A step's workpiece selection is data set by generators (e.g. the + Material Test Grid links its step to the workpiece it created); + this page makes that selection visible, it does not edit it. It + refreshes on step and layer changes so it stays current while the + dialog is open. + """ + + show_identity = False + + def __init__(self, editor: "DocEditor", step: Any): + super().__init__(editor, step) + self._group = Adw.PreferencesGroup( + title=_("Workpieces"), + description=_("The workpieces this step processes."), + ) + self.add(self._group) + self._rows: list[Gtk.Widget] = [] + self._layer = None + self._connect_layer() + self._refresh() + + def _connect_layer(self): + layer = self.step.layer + if layer is None: + return + self._layer = layer + layer.descendant_added.connect(self._on_layer_changed) + layer.descendant_removed.connect(self._on_layer_changed) + layer.descendant_updated.connect(self._on_layer_changed) + + def _disconnect_layer(self): + layer = self._layer + if layer is None: + return + self._layer = None + for signal in ( + "descendant_added", + "descendant_removed", + "descendant_updated", + ): + try: + getattr(layer, signal).disconnect(self._on_layer_changed) + except KeyError: + pass + + def _on_layer_changed(self, *args, **kwargs): + self._refresh() + + def _sync_widgets_to_model(self, *args): + super()._sync_widgets_to_model() + if self.step.layer is not self._layer: + self._disconnect_layer() + self._connect_layer() + self._refresh() + + def _refresh(self): + """Rebuild the rows from the step's current filter and layer.""" + self._clear_rows() + if self._layer is None: + return + workpieces = self._layer.all_workpieces + uids = set(self.step.workpiece_uids) + if not uids: + summary = _("All workpieces on this layer") + else: + included = sum(1 for wp in workpieces if wp.uid in uids) + summary = _("Processes {count} of {total} workpieces").format( + count=included, total=len(workpieces) + ) + self._add_row(Adw.ActionRow(title=summary)) + for wp in workpieces: + self._add_row(self._workpiece_row(wp, uids)) + + def _add_row(self, row: Adw.ActionRow): + self._rows.append(row) + self._group.add(row) + + @staticmethod + def _workpiece_row(wp, uids: set[str]) -> Adw.ActionRow: + """One non-interactive row per workpiece of the layer.""" + row = Adw.ActionRow(title=wp.name) + if uids and wp.uid not in uids: + row.set_sensitive(False) + icon = get_icon("action-unavailable-symbolic") + else: + icon = get_icon("object-select-symbolic") + icon.set_valign(Gtk.Align.CENTER) + row.add_suffix(icon) + return row + + def _clear_rows(self): + for row in self._rows: + self._group.remove(row) + self._rows = [] + + def _cleanup(self): + self._disconnect_layer() + super()._cleanup() diff --git a/tests/core/test_step.py b/tests/core/test_step.py index 8175a1981..e5f1d2de5 100644 --- a/tests/core/test_step.py +++ b/tests/core/test_step.py @@ -6,6 +6,7 @@ from rayforge.core.doc import Doc from rayforge.core.step import Step +from rayforge.core.workpiece import WorkPiece from rayforge.machine.models.laser import LaserHead from rayforge.machine.models.spindle import SpindleHead @@ -34,7 +35,7 @@ def test_step_initialization(step): assert step.name == "Test Step" assert step.visible is True assert step.selected_head_uid is None - assert step.generated_workpiece_uid is None + assert step.workpiece_uids == [] assert step.applied_recipe_uid is None assert step.per_workpiece_transformers_dicts == [] assert step.per_step_transformers_dicts == [] @@ -159,7 +160,7 @@ def test_serialization_to_dict_all_properties(step): step.matrix = Matrix.translation(1, 2) step.visible = False step.selected_head_uid = "laser-abc" - step.generated_workpiece_uid = "wp-xyz" + step.workpiece_uids = ["wp-xyz"] step.applied_recipe_uid = "recipe-123" step.per_step_transformers_dicts = [{"type": "CoolingPause"}] step.pixels_per_mm = (100, 100) @@ -175,7 +176,7 @@ def test_serialization_to_dict_all_properties(step): assert data["typelabel"] == "TestType" assert data["visible"] is False assert data["selected_head_uid"] == "laser-abc" - assert data["generated_workpiece_uid"] == "wp-xyz" + assert data["workpiece_uids"] == ["wp-xyz"] assert data["applied_recipe_uid"] == "recipe-123" assert data["per_step_transformers_dicts"] == [{"type": "CoolingPause"}] assert data["pixels_per_mm"] == (100, 100) @@ -193,7 +194,7 @@ def test_deserialization_from_dict(step): "typelabel": "RestoredType", "visible": False, "selected_head_uid": "laser-def", - "generated_workpiece_uid": "wp-123", + "workpiece_uids": ["wp-123"], "applied_recipe_uid": "recipe-456", "per_workpiece_transformers_dicts": [], "per_step_transformers_dicts": [], @@ -213,7 +214,7 @@ def test_deserialization_from_dict(step): assert restored.typelabel == "RestoredType" assert restored.visible is False assert restored.selected_head_uid == "laser-def" - assert restored.generated_workpiece_uid == "wp-123" + assert restored.workpiece_uids == ["wp-123"] assert restored.applied_recipe_uid == "recipe-456" assert restored.pixels_per_mm == (20, 20) assert restored.cut_speed == 2500 @@ -288,7 +289,7 @@ def test_step_forward_compatibility_with_extra_fields(): "typelabel": "FutureType", "visible": True, "selected_head_uid": None, - "generated_workpiece_uid": None, + "workpiece_uids": [], "applied_recipe_uid": None, "per_workpiece_transformers_dicts": [], "per_step_transformers_dicts": [], @@ -335,7 +336,7 @@ def test_step_backward_compatibility_with_missing_optional_fields(): step = Step.from_dict(minimal_dict) assert step.selected_head_uid is None - assert step.generated_workpiece_uid is None + assert step.workpiece_uids == [] assert step.applied_recipe_uid is None assert step.pixels_per_mm == (100, 100) assert step.cut_speed == 500 @@ -370,6 +371,89 @@ def test_legacy_selected_laser_uid_key_migrates(): assert "selected_laser_uid" not in data +def test_legacy_generated_workpiece_uid_key_migrates(): + """ + Old project files keyed the workpiece filter as a single + "generated_workpiece_uid". It must load into workpiece_uids and not + pollute extra. + """ + step_dict = { + "uid": "step-legacy-wp", + "type": "step", + "typelabel": "LegacyType", + "visible": True, + "matrix": Matrix.identity().to_list(), + "per_workpiece_transformers_dicts": [], + "per_step_transformers_dicts": [], + "generated_workpiece_uid": "old-wp-uid", + } + + step = Step.from_dict(step_dict) + + assert step.workpiece_uids == ["old-wp-uid"] + assert "generated_workpiece_uid" not in step.extra + + data = step.to_dict() + assert data["workpiece_uids"] == ["old-wp-uid"] + assert data["generated_workpiece_uid"] == "old-wp-uid" + + +def test_workpiece_uids_key_wins_over_legacy_key(): + """A present-day workpiece_uids key beats a stale legacy key.""" + step_dict = { + "uid": "step-both-wp", + "type": "step", + "typelabel": "BothType", + "visible": True, + "matrix": Matrix.identity().to_list(), + "per_workpiece_transformers_dicts": [], + "per_step_transformers_dicts": [], + "workpiece_uids": ["new-wp-uid"], + "generated_workpiece_uid": "old-wp-uid", + } + + step = Step.from_dict(step_dict) + + assert step.workpiece_uids == ["new-wp-uid"] + + +def test_selected_workpieces_empty_filter_matches_all(): + """An empty filter matches every given workpiece, in order.""" + step = Step(typelabel="Test") + wp_a = WorkPiece(name="a") + wp_b = WorkPiece(name="b") + + assert step.selected_workpieces([wp_a, wp_b]) == [wp_a, wp_b] + + +def test_selected_workpieces_filter_matches_listed_uids(): + """A set filter matches only the listed uids, in input order.""" + step = Step(typelabel="Test") + wp_a = WorkPiece(name="a") + wp_b = WorkPiece(name="b") + wp_c = WorkPiece(name="c") + wp_a.uid = "wp-a" + wp_b.uid = "wp-b" + wp_c.uid = "wp-c" + step.workpiece_uids = ["wp-c", "wp-a"] + + assert step.selected_workpieces([wp_a, wp_b, wp_c]) == [wp_a, wp_c] + + +def test_set_workpiece_uids_normalizes_and_signals(step): + """set_workpiece_uids dedupes and fires updated only on change.""" + handler = MagicMock() + step.updated.connect(handler) + + step.set_workpiece_uids(["wp-a", "wp-b", "wp-a"]) + assert step.workpiece_uids == ["wp-a", "wp-b"] + handler.assert_called_once_with(step) + handler.reset_mock() + + step.set_workpiece_uids(["wp-a", "wp-b"]) + handler.assert_not_called() + + def test_capability_defaults_applied_in_constructor(): """ Capability defaults no longer drive attribute creation on core diff --git a/tests/pipeline/test_intent_builder.py b/tests/pipeline/test_intent_builder.py index 7fd3b4c18..9c2a56bdc 100644 --- a/tests/pipeline/test_intent_builder.py +++ b/tests/pipeline/test_intent_builder.py @@ -113,13 +113,13 @@ def test_builds_one_node_per_workpiece_plus_step_and_job(isolated_machine): assert len(keys) == 6 -def test_step_with_generated_workpiece_only_applies_to_that_workpiece( +def test_step_with_workpiece_filter_only_applies_to_listed_workpieces( isolated_machine, ): - """A step that generated a workpiece (e.g. material test) must not - be applied to the other workpieces in its layer.""" + """A step with a workpiece filter (e.g. material test) must not be + applied to the other workpieces in its layer.""" step = _TestStep(name="s1") - step.generated_workpiece_uid = "wp-gen" + step.workpiece_uids = ["wp-gen"] wp_gen = WorkPiece(name="wp-gen") wp_gen.uid = "wp-gen" wp_other = WorkPiece(name="wp-other") @@ -131,13 +131,13 @@ def test_step_with_generated_workpiece_only_applies_to_that_workpiece( assert workpiece_key("wp-other", step.uid) not in keys -def test_step_with_missing_generated_workpiece_emits_no_nodes( +def test_step_with_missing_filtered_workpiece_emits_no_nodes( isolated_machine, ): - """When a step's generated workpiece is not in the layer, the step + """When a step's filter matches no workpiece in the layer, the step emits no workpiece or step aggregate nodes.""" step = _TestStep(name="s1") - step.generated_workpiece_uid = "wp-gone" + step.workpiece_uids = ["wp-gone"] doc = _make_doc(step, WorkPiece(name="wp-other")) nodes = IntentBuilder(machine=isolated_machine).build(doc) diff --git a/tests/ui_gtk/doceditor/step_settings/test_workpieces_page.py b/tests/ui_gtk/doceditor/step_settings/test_workpieces_page.py new file mode 100644 index 000000000..6700d8e79 --- /dev/null +++ b/tests/ui_gtk/doceditor/step_settings/test_workpieces_page.py @@ -0,0 +1,108 @@ +# flake8: noqa: E402 +"""Tests for the read-only Workpieces settings page.""" + +from collections import deque + +import gi +import pytest + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") + +from rayforge.core.step import Step +from rayforge.core.workpiece import WorkPiece +from rayforge.ui_gtk.doceditor.step_settings.dialog import StepSettingsDialog +from rayforge.ui_gtk.doceditor.step_settings.pages.workpieces import ( + WorkpiecesPage, +) + + +@pytest.fixture +def attached_step(editor): + """A step on the editor's active layer, with two workpieces.""" + step = Step(typelabel="Fake", name="fake") + layer = editor.doc.active_layer + assert layer.workflow is not None + layer.workflow.add_step(step) + wp_a = WorkPiece(name="wp-a") + wp_b = WorkPiece(name="wp-b") + wp_a.uid = "wp-a" + wp_b.uid = "wp-b" + layer.add_child(wp_a) + layer.add_child(wp_b) + return step, layer + + +def _row_titles(page): + """Titles of all rows in the page's group, descending into the + wrapper boxes Adw.PreferencesGroup adds around its rows.""" + titles = [] + queue = deque([page._group.get_first_child()]) + while queue: + child = queue.popleft() + while child is not None: + if hasattr(child, "get_title"): + titles.append(child.get_title()) + else: + queue.append(child.get_first_child()) + child = child.get_next_sibling() + return titles + + +@pytest.mark.ui +def test_page_lists_all_workpieces_without_filter(editor, attached_step): + step, _layer = attached_step + page = WorkpiecesPage(editor, step) + + assert _row_titles(page) == [ + "All workpieces on this layer", + "wp-a", + "wp-b", + ] + page._cleanup() + + +@pytest.mark.ui +def test_page_marks_excluded_workpieces(editor, attached_step): + step, _layer = attached_step + step.workpiece_uids = ["wp-b"] + page = WorkpiecesPage(editor, step) + + assert _row_titles(page) == [ + "Processes 1 of 2 workpieces", + "wp-a", + "wp-b", + ] + page._cleanup() + + +@pytest.mark.ui +def test_page_refreshes_when_filter_changes(editor, attached_step): + step, _layer = attached_step + page = WorkpiecesPage(editor, step) + + step.set_workpiece_uids(["wp-a"]) + assert _row_titles(page)[0] == "Processes 1 of 2 workpieces" + + step.set_workpiece_uids([]) + assert _row_titles(page)[0] == "All workpieces on this layer" + page._cleanup() + + +@pytest.mark.ui +def test_dialog_adds_page_for_layer_step(editor, attached_step): + step, _layer = attached_step + dialog = StepSettingsDialog(editor, step) + + assert dialog.workpieces_view is not None + assert dialog.btn_workpieces is not None + dialog.close() + + +@pytest.mark.ui +def test_dialog_omits_page_for_detached_step(editor, step): + dialog = StepSettingsDialog(editor, step) + + assert dialog.workpieces_view is None + assert dialog.btn_workpieces is None + dialog.close() From c4300f5c9c03acc08f499e1ed57ae15672be0c15 Mon Sep 17 00:00:00 2001 From: Samuel Abels Date: Thu, 15 Oct 2026 13:12:21 +0200 Subject: [PATCH 087/104] docs: add PR template enforcement and AI policy Add a CONTRIBUTING.md with an AI-assisted code clause and a pull request template that asks for architectural reasoning and manual test steps. A GitHub Actions workflow validates the description and closes non-compliant PRs automatically, reopening them once the description complies; drafts, bots, and users with write access are exempt. Sync the website developer docs and contributing pages in all languages, and instruct agents to leave .po catalogs to the release process and to ship docs and screenshot updates in PRs. --- .github/PULL_REQUEST_TEMPLATE.md | 26 ++ .github/scripts/check_pr_template.py | 232 ++++++++++++++++++ .github/workflows/pr-template-check.yml | 31 +++ AGENTS.md | 4 + CONTRIBUTING.md | 22 ++ RELEASE_MANIFEST.md | 3 + docs/agents/translations.md | 14 +- docs/agents/website.md | 4 + .../getting-started/submitting-changes.md | 24 +- .../getting-started/submitting-changes.md | 27 +- .../contributing.js | 11 + .../getting-started/submitting-changes.md | 26 +- .../contributing.js | 11 + .../getting-started/submitting-changes.md | 27 +- .../contributing.js | 11 + .../getting-started/submitting-changes.md | 26 +- .../contributing.js | 9 + .../getting-started/submitting-changes.md | 26 +- .../contributing.js | 11 + .../getting-started/submitting-changes.md | 30 ++- .../contributing.js | 11 + .../getting-started/submitting-changes.md | 22 +- .../contributing.js | 9 + website/src/pages/contributing.js | 11 + 24 files changed, 597 insertions(+), 31 deletions(-) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100755 .github/scripts/check_pr_template.py create mode 100644 .github/workflows/pr-template-check.yml create mode 100644 CONTRIBUTING.md diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..2da525b9f --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,26 @@ +**PRs that do not fill out this template are rejected without review.** + +**What does this PR change?** + +A clear and concise description of the change and the problem it solves. + +**Why did you choose this specific architectural approach?** + +Explain your reasoning in terms of Rayforge's existing architecture. Which +alternatives did you consider, and why did you reject them? + +**How did you manually test this locally?** + +Describe the exact steps you performed to verify the change, so a maintainer +can reproduce them: + +1. +2. +3. + +**Checklist** + +- [ ] I am not an AI. I have manually read the code and understand it. +- [ ] I take full responsibility for the quality and architectural fit of + this code. +- [ ] I agree to publish this code under the MIT license. diff --git a/.github/scripts/check_pr_template.py b/.github/scripts/check_pr_template.py new file mode 100755 index 000000000..7a47cc0c8 --- /dev/null +++ b/.github/scripts/check_pr_template.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python3 +"""Check that a pull request description follows the PR template. + +Pull requests that leave template sections empty or the checklist +unticked are closed automatically with a comment listing what is +missing. Editing the description into compliance reopens the pull +request. Drafts, bots, and users with write access to the repository +are exempt. +""" + +import argparse +import json +import os +import re +import subprocess + +MARKER = "" +LABEL = "invalid-template" +MIN_ANSWER_CHARS = 30 + +BOT_LOGINS = ("dependabot[bot]", "github-actions[bot]") + +HEADING_RE = re.compile(r"^\s*\*\*(.+?)\*\*\s*$", re.MULTILINE) + +SECTION_HEADINGS = ( + "What does this PR change?", + "Why did you choose this specific architectural approach?", + "How did you manually test this locally?", + "Checklist", +) + +SECTION_PLACEHOLDERS = { + "What does this PR change?": ( + ( + "A clear and concise description of the change and the " + "problem it solves." + ), + ), + "Why did you choose this specific architectural approach?": ( + ( + "Explain your reasoning in terms of Rayforge's existing " + "architecture. Which alternatives did you consider, and why " + "did you reject them?" + ), + ), + "How did you manually test this locally?": ( + ( + "Describe the exact steps you performed to verify the change, " + "so a maintainer can reproduce them:" + ), + "1. 2. 3.", + ), +} + +CHECKBOX_STATEMENTS = ( + "I am not an AI. I have manually read the code and understand it.", + ( + "I take full responsibility for the quality and architectural " + "fit of this code." + ), + "I agree to publish this code under the MIT license.", +) + + +def gh_api(*args): + proc = subprocess.run( + ["gh", "api", *args], capture_output=True, text=True, check=False + ) + if proc.returncode != 0: + raise RuntimeError(f"gh api {args}: {proc.stderr.strip()}") + return json.loads(proc.stdout) + + +def gh_run(*args, stdin=None): + proc = subprocess.run( + ["gh", *args], input=stdin, capture_output=True, text=True, check=False + ) + if proc.returncode != 0: + raise RuntimeError(f"gh {args}: {proc.stderr.strip()}") + return proc.stdout + + +def normalize(text): + return re.sub(r"\s+", " ", text or "").strip() + + +def split_sections(body): + matches = list(HEADING_RE.finditer(body)) + sections = {} + for index, match in enumerate(matches): + if index + 1 < len(matches): + end = matches[index + 1].start() + else: + end = len(body) + heading = normalize(match.group(1)) + sections[heading] = normalize(body[match.end() : end]) + return sections + + +def answer_text(text, heading): + for placeholder in SECTION_PLACEHOLDERS.get(heading, ()): + text = text.replace(normalize(placeholder), "") + return text.strip() + + +def check_checklist(text): + problems = [] + for statement in CHECKBOX_STATEMENTS: + pattern = re.compile( + r"[-*+] \[([x ])\] " + re.escape(statement), re.IGNORECASE + ) + match = pattern.search(text) + if not match: + problems.append(f'checklist item "{statement}" is missing') + elif match.group(1).lower() != "x": + problems.append(f'checklist item "{statement}" is not ticked') + return problems + + +def find_problems(body): + sections = split_sections(body) + problems = [] + for heading in SECTION_HEADINGS: + if heading not in sections: + problems.append(f'section "**{heading}**" is missing') + continue + if heading == "Checklist": + problems.extend(check_checklist(sections[heading])) + continue + answer = answer_text(sections[heading], heading) + if len(answer) < MIN_ANSWER_CHARS: + problems.append(f'section "**{heading}**" is not filled out') + return problems + + +def rejection_comment(repo, problems): + template = ( + f"https://github.com/{repo}/blob/main/.github/PULL_REQUEST_TEMPLATE.md" + ) + contributing = f"https://github.com/{repo}/blob/main/CONTRIBUTING.md" + items = "\n".join(f"- {problem}" for problem in problems) + return ( + f"{MARKER}\n\n" + "This pull request was closed automatically because its " + "description does not follow the " + f"[pull request template]({template}). Problems found:\n\n" + f"{items}\n\n" + "Please edit the pull request description to answer every " + "section and to tick every checkbox; as soon as it complies, " + "the pull request is reopened automatically. See " + f"[CONTRIBUTING.md]({contributing})." + ) + + +def reopen_comment(): + return ( + f"{MARKER}\n\n" + "The description now follows the pull request template, so " + "this pull request was reopened automatically." + ) + + +def comment_exists(repo, number, body): + comments = gh_api("--paginate", f"repos/{repo}/issues/{number}/comments") + return any(c.get("body") == body for c in comments) + + +def has_write_access(repo, login): + response = gh_api(f"repos/{repo}/collaborators/{login}/permission") + return response.get("permission") in ("admin", "write") + + +def has_label(pr, name): + return any(l.get("name") == name for l in pr.get("labels", [])) + + +def reject(repo, number, problems): + comment = rejection_comment(repo, problems) + if not comment_exists(repo, number, comment): + gh_run("pr", "comment", str(number), "--body-file", "-", stdin=comment) + gh_run("pr", "close", str(number)) + gh_run( + "label", + "create", + LABEL, + "--force", + "--color", + "E99695", + "--description", + "Does not follow the pull request template", + ) + gh_run("pr", "edit", str(number), "--add-label", LABEL) + + +def unlabel_and_reopen(repo, number, pr): + gh_run("pr", "edit", str(number), "--remove-label", LABEL) + if pr["state"] == "closed" and not pr.get("merged_at"): + gh_run("pr", "reopen", str(number)) + gh_run( + "pr", + "comment", + str(number), + "--body-file", + "-", + stdin=reopen_comment(), + ) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--pr-number", type=int, required=True) + args = parser.parse_args() + + repo = os.environ["GITHUB_REPOSITORY"] + pr = gh_api(f"repos/{repo}/pulls/{args.pr_number}") + if pr.get("draft"): + return + login = pr.get("user", {}).get("login", "") + if login in BOT_LOGINS or has_write_access(repo, login): + return + + problems = find_problems(pr.get("body") or "") + if problems: + if pr["state"] == "open": + reject(repo, args.pr_number, problems) + return + if has_label(pr, LABEL): + unlabel_and_reopen(repo, args.pr_number, pr) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/pr-template-check.yml b/.github/workflows/pr-template-check.yml new file mode 100644 index 000000000..57b123c82 --- /dev/null +++ b/.github/workflows/pr-template-check.yml @@ -0,0 +1,31 @@ +name: PR Template Check + +on: + pull_request_target: + types: + - opened + - edited + - reopened + - ready_for_review + +permissions: + issues: write + pull-requests: write + +jobs: + check: + if: >- + github.event.pull_request.author_association != 'OWNER' && + github.event.pull_request.author_association != 'MEMBER' && + github.event.pull_request.author_association != 'COLLABORATOR' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Check the pull request template + env: + GH_TOKEN: ${{ github.token }} + run: | + python3 .github/scripts/check_pr_template.py \ + --pr-number "${{ github.event.pull_request.number }}" diff --git a/AGENTS.md b/AGENTS.md index 0c1c70658..3a3306e95 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,10 @@ Run targeted tests - Never use "head" to filter CLI commands; it hides useful error messages - Do not make changes unrelated to the current task +- Do not update `.po`/`.pot` translation catalogs in feature or bug PRs; + catalogs are only updated during release preparation +- Feature or bug PRs that change user-visible behavior include updated + website docs and regenerated screenshot targets where applicable - Never remove logging or debugging unless asked by the user - In answers, put each file into its own markdown code block. File start markers belong outside the block diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..8aebe697a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,22 @@ +# Contributing to Rayforge + +Thank you for your interest in contributing! + +- **Bugs and feature ideas:** open a + [GitHub issue](https://github.com/barebaric/rayforge/issues/new/choose). +- **Code changes:** read the + [developer documentation](https://rayforge.org/docs/developer/getting-started) + to learn how to build, test, and lint your changes. +- **Pull requests** must follow the + [pull request template](.github/PULL_REQUEST_TEMPLATE.md). PRs that do not + fill it out are rejected. + +## AI-Assisted Code + +We welcome the use of AI tools to help write code. However, you are strictly +responsible for the code you submit. You MUST fully understand it and must be +able to explain the architectural choices. + +AI-generated code often introduces subtle bugs, architectural mismatches, and +security flaws. PRs that appear to be raw, unreviewed AI output will be closed +to respect maintainer time. diff --git a/RELEASE_MANIFEST.md b/RELEASE_MANIFEST.md index 5b11eed0e..e840de735 100644 --- a/RELEASE_MANIFEST.md +++ b/RELEASE_MANIFEST.md @@ -29,6 +29,9 @@ Generate the following files in `media/[release]/drafts/`: - `blog_post.md` - Formatted post for the website blog. Don't store this in the drafts, store it in `website/content/blog/posts/`. Give credits to paying Patreon supporters. - Update the changelog in the appstream file (`data/org.rayforge.rayforge.metainfo.xml`) +- Regenerate the gettext catalogs with `pixi run update-translations` and translate the new + strings (see `docs/agents/translations.md`); `.po` files are not updated outside release + preparation - Generate five release thumbnails using the MCP tool. Something like "make a YouTube thumbnail for Rayforge 1.1 with ... [something creative]". Put the thumbnails into media/[release]/thumbs/ - Depending on the changes, check that the user documentation on the website is up to date. Check the docs by reading the application code. diff --git a/docs/agents/translations.md b/docs/agents/translations.md index c31fbdd89..889689e9a 100644 --- a/docs/agents/translations.md +++ b/docs/agents/translations.md @@ -15,16 +15,20 @@ headings and bodies, validation and error messages. ## Updating the catalogs -- Wrapping a string only marks it for extraction. New messages must also be - added to the gettext template `rayforge/locale/rayforge.pot` and translated - in `rayforge/locale//LC_MESSAGES/rayforge.po` for every shipped +- Do not update `rayforge/locale/rayforge.pot` or any `.po` file as part of a + feature or bug fix PR. Wrapping a string with `_()` only marks it for + extraction; the catalogs are regenerated and translated as part of the + release preparation process +- During release preparation, new messages are added to the gettext template + `rayforge/locale/rayforge.pot` and translated in + `rayforge/locale//LC_MESSAGES/rayforge.po` for every shipped language (list them with `ls rayforge/locale`). The `en` catalog keeps empty translations, since it falls back to the source text -- Only add or update entries for the strings of your own change; do not - rewrite unrelated catalog entries ## Avoiding catalog churn +Applies when catalogs are updated during release preparation. + - `pixi run update-translations` rewrites every catalog in the repository, including the addon catalogs under `rayforge/builtin_addons/`. Most of those files then only differ by a fresh `POT-Creation-Date` and reshuffled entries diff --git a/docs/agents/website.md b/docs/agents/website.md index f745a9fb4..8542065e3 100644 --- a/docs/agents/website.md +++ b/docs/agents/website.md @@ -9,5 +9,9 @@ relevant page in `website/docs` and the matching page in every translated directory. Purely internal changes, refactors, and error-handling details do not need documentation +- When a change alters UI that the docs show, regenerate the affected + screenshots: pick the matching target from the `TARGETS` registry in + `scripts/screenshot/cli.py` and run `pixi run screenshot `; the + images are written to `website/static/screenshots` - Run `pixi run site-format` after editing website markdown so Prettier formatting stays consistent diff --git a/website/docs/developer/getting-started/submitting-changes.md b/website/docs/developer/getting-started/submitting-changes.md index 00542108e..03d69dc25 100644 --- a/website/docs/developer/getting-started/submitting-changes.md +++ b/website/docs/developer/getting-started/submitting-changes.md @@ -55,11 +55,21 @@ git rebase upstream/main git push origin feature/your-feature-name ``` -2. Create a pull request on GitHub with: - - A clear title describing the change - - A detailed description of what you changed and why +2. Create a pull request on GitHub and fill out the pull request template: + - A clear title and a description of the change and the problem it solves + - Why you chose your specific architectural approach and which alternatives you rejected + - The exact steps you used to test the change locally - Reference to any related issues - Screenshots if the change affects the UI + - Every checkbox in the template's checklist ticked + + +:::warning[Template Enforcement] +Pull requests that do not fill out the template are closed automatically by a +bot. Edit the pull request description to answer every section and to tick +every checkbox — the pull request is then reopened automatically. +::: + ## Code Review Process @@ -77,6 +87,14 @@ Pull requests are merged when they: - [ ] Have documentation updates if needed - [ ] Are approved by at least one maintainer +## AI-Assisted Code + +We welcome the use of AI tools to help write code. However, you are strictly responsible for the +code you submit. You MUST fully understand it and must be able to explain the architectural choices. + +AI-generated code often introduces subtle bugs, architectural mismatches, and security flaws. Pull +requests that appear to be raw, unreviewed AI output will be closed to respect maintainer time. + ## Additional Guidelines ### Commit Messages diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/developer/getting-started/submitting-changes.md b/website/i18n/de/docusaurus-plugin-content-docs/current/developer/getting-started/submitting-changes.md index 0f2ee2809..1a152c70b 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/developer/getting-started/submitting-changes.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/developer/getting-started/submitting-changes.md @@ -49,11 +49,22 @@ git rebase upstream/main git push origin feature/ihr-feature-name ``` -2. Erstelle einen Pull-Request auf GitHub mit: - - Einer klaren Überschrift, die die Änderung beschreibt - - Einer detaillierten Beschreibung, was du geändert hast und warum +2. Erstelle einen Pull-Request auf GitHub und fülle die Pull-Request-Vorlage aus: + - Eine klare Überschrift und eine Beschreibung der Änderung und des Problems, das sie löst + - Warum du deinen konkreten Architektur-Ansatz gewählt hast und welche Alternativen du verworfen + hast + - Die genauen Schritte, mit denen du die Änderung lokal getestet hast - Referenz auf verwandte Issues - Screenshots, wenn die Änderung die UI betrifft + - Jede Checkbox der Checkliste in der Vorlage angekreuzt + + +:::warning[Vorlage wird erzwungen] +Pull-Requests, die die Vorlage nicht ausfüllen, werden automatisch von einem Bot geschlossen. +Bearbeite die Beschreibung des Pull-Requests, sodass jeder Abschnitt beantwortet und jede Checkbox +angekreuzt ist — der Pull-Request wird danach automatisch wieder geöffnet. +::: + ## Code-Review-Prozess @@ -71,6 +82,16 @@ Pull-Requests werden gemergt, wenn sie: - [ ] Dokumentations-Updates haben, falls erforderlich - [ ] Von mindestens einem Maintainer genehmigt sind +## KI-unterstützter Code + +Wir begrüßen die Nutzung von KI-Werkzeugen beim Schreiben von Code. Trotzdem bist du streng für den +von dir eingereichten Code verantwortlich. Du MUSST ihn vollständig verstehen und musst in der Lage +sein, die Architekturentscheidungen zu erklären. + +KI-generierter Code führt oft subtile Bugs, architektonische Diskrepanzen und Sicherheitslücken ein. +Pull-Requests, die wie rohe, unüberprüfte KI-Ausgaben wirken, werden geschlossen, um die Zeit der +Maintainer zu respektieren. + ## Zusätzliche Richtlinien ### Commit-Nachrichten diff --git a/website/i18n/de/docusaurus-plugin-content-pages/contributing.js b/website/i18n/de/docusaurus-plugin-content-pages/contributing.js index d51b13752..56c569a08 100644 --- a/website/i18n/de/docusaurus-plugin-content-pages/contributing.js +++ b/website/i18n/de/docusaurus-plugin-content-pages/contributing.js @@ -131,6 +131,17 @@ export default function Contributing() { Quellcode ansehen GitHub Repository + + + Mitwirkungsregeln + + CONTRIBUTING.md + Sponsor werden diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/developer/getting-started/submitting-changes.md b/website/i18n/es/docusaurus-plugin-content-docs/current/developer/getting-started/submitting-changes.md index 1f9a0848f..9321f297c 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/developer/getting-started/submitting-changes.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/developer/getting-started/submitting-changes.md @@ -49,11 +49,21 @@ git rebase upstream/main git push origin feature/nombre-de-tu-feature ``` -2. Crea un pull request en GitHub con: - - Un título claro describiendo el cambio - - Una descripción detallada de qué cambiaste y por qué +2. Crea un pull request en GitHub y rellena la plantilla de pull request: + - Un título claro y una descripción del cambio y del problema que resuelve + - Por qué elegiste tu enfoque arquitectónico concreto y qué alternativas descartaste + - Los pasos exactos que seguiste para probar el cambio localmente - Referencia a cualquier issue relacionado - Capturas de pantalla si el cambio afecta la UI + - Todas las casillas de la checklist de la plantilla marcadas + + +:::warning[La plantilla se aplica] +Los pull requests que no rellenan la plantilla se cierran automáticamente por un bot. Edita la +descripción del pull request para responder cada sección y marcar cada casilla; el pull request se +reabre automáticamente. +::: + ## Proceso de Revisión de Código @@ -71,6 +81,16 @@ Los pull requests se mergean cuando: - [ ] Tienen actualizaciones de documentación si es necesario - [ ] Son aprobados por al menos un maintainer +## Código Asistido por IA + +Agradecemos el uso de herramientas de IA para ayudar a escribir código. Sin embargo, eres +estrictamente responsable del código que envías. DEBES entenderlo completamente y debes poder +explicar las decisiones arquitectónicas. + +El código generado por IA a menudo introduce errores sutiles, desajustes arquitectónicos y fallos de +seguridad. Los pull requests que parezcan salida de IA sin revisar se cerrarán para respetar el +tiempo de los maintainers. + ## Directrices Adicionales ### Mensajes de Commit diff --git a/website/i18n/es/docusaurus-plugin-content-pages/contributing.js b/website/i18n/es/docusaurus-plugin-content-pages/contributing.js index 52691d494..0b1b5d8df 100644 --- a/website/i18n/es/docusaurus-plugin-content-pages/contributing.js +++ b/website/i18n/es/docusaurus-plugin-content-pages/contributing.js @@ -130,6 +130,17 @@ export default function Contributing() { Explorar el código Repositorio de GitHub + + + Reglas de contribución + + CONTRIBUTING.md + Convertirse en Patrocinador diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/developer/getting-started/submitting-changes.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/developer/getting-started/submitting-changes.md index 7e040837b..449c42820 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/developer/getting-started/submitting-changes.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/developer/getting-started/submitting-changes.md @@ -49,11 +49,22 @@ git rebase upstream/main git push origin feature/votre-nom-de-fonctionnalite ``` -2. Créez une pull request sur GitHub avec : - - Un titre clair décrivant le changement - - Une description détaillée de ce que vous avez changé et pourquoi +2. Créez une pull request sur GitHub et remplissez le modèle de pull request : + - Un titre clair et une description du changement et du problème qu'il résout + - Pourquoi vous avez choisi votre approche architecturale et quelles alternatives vous avez + écartées + - Les étapes exactes que vous avez suivies pour tester le changement localement - Référence aux tickets liés le cas échéant - Captures d'écran si le changement affecte l'interface + - Toutes les cases de la liste de contrôle du modèle cochées + + +:::warning[Application du modèle] +Les pull requests qui ne remplissent pas le modèle sont fermées automatiquement par un bot. +Modifiez la description de la pull request pour répondre à chaque section et cocher chaque case ; +la pull request est ensuite rouverte automatiquement. +::: + ## Processus de revue de code @@ -71,6 +82,16 @@ Les pull requests sont fusionnées lorsqu'elles : - [ ] Ont des mises à jour de documentation si nécessaire - [ ] Sont approuvées par au moins un mainteneur +## Code assisté par IA + +Nous accueillons l'utilisation d'outils d'IA pour aider à écrire du code. Cependant, vous êtes +strictement responsable du code que vous soumettez. Vous DEVEZ le comprendre entièrement et être +capable d'expliquer les choix architecturaux. + +Le code généré par IA introduit souvent des bugs subtils, des inadéquations architecturales et des +failles de sécurité. Les pull requests qui semblent être des sorties d'IA brutes et non relues +seront fermées pour respecter le temps des mainteneurs. + ## Directives supplémentaires ### Messages de commit diff --git a/website/i18n/fr/docusaurus-plugin-content-pages/contributing.js b/website/i18n/fr/docusaurus-plugin-content-pages/contributing.js index 728f4245d..108a40d9a 100644 --- a/website/i18n/fr/docusaurus-plugin-content-pages/contributing.js +++ b/website/i18n/fr/docusaurus-plugin-content-pages/contributing.js @@ -130,6 +130,17 @@ export default function Contributing() { Parcourir le code source Dépôt GitHub + + + Règles de contribution + + CONTRIBUTING.md + Devenir Sponsor diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/developer/getting-started/submitting-changes.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/developer/getting-started/submitting-changes.md index b13adc0b6..edbf16edb 100644 --- a/website/i18n/hi/docusaurus-plugin-content-docs/current/developer/getting-started/submitting-changes.md +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/developer/getting-started/submitting-changes.md @@ -55,11 +55,21 @@ git rebase upstream/main git push origin feature/your-feature-name ``` -2. GitHub पर एक पुल अनुरोध बनाएँ जिसमें: - - बदलाव वर्णित करने वाला एक स्पष्ट शीर्षक - - आपने क्या और क्यों बदला इसका एक विस्तृत विवरण +2. GitHub पर एक पुल अनुरोध बनाएँ और पुल अनुरोध टेम्पलेट भरें: + - एक स्पष्ट शीर्षक और बदलाव तथा उसके हल की समस्या का विवरण + - आपने अपना विशिष्ट आर्किटेक्चरल दृष्टिकोण क्यों चुना और कौन से विकल्प छोड़े + - बदलाव का स्थानीय रूप से परीक्षण करने के सटीक चरण - किसी भी संबंधित समस्या का संदर्भ - यदि बदलाव UI को प्रभावित करता है तो स्क्रीनशॉट + - टेम्पलेट की चेकलिस्ट के सभी चेकबॉक्स चिह्नित + + +:::warning[टेम्पलेट लागू] +जो पुल अनुरोध टेम्पलेट नहीं भरते उन्हें एक बॉट द्वारा स्वतः बंद कर दिया जाता है। पुल अनुरोध +विवरण को संपादित करें ताकि हर खंड भरा हो और हर चेकबॉक्स चिह्नित हो — पुल अनुरोध फिर स्वतः फिर से +खुल जाता है। +::: + ## कोड समीक्षा प्रक्रिया @@ -77,6 +87,16 @@ git rebase upstream/main - [ ] आवश्यकता होने पर दस्तावेज़ीकरण अपडेट रखते हैं - [ ] कम से कम एक अनुरक्षक द्वारा स्वीकृत हों +## एआई-सहायता प्राप्त कोड + +हम कोड लिखने में मदद के लिए एआई उपकरणों के उपयोग का स्वागत करते हैं। हालाँकि, आप अपने जमा किए गए कोड +के लिए पूरी तरह ज़िम्मेदार हैं। आपको इसे पूरी तरह समझना चाहिए और आर्किटेक्चरल निर्णयों को समझाने में +सक्षम होना चाहिए। + +एआई-जनित कोड अक्सर सूक्ष्म बग, आर्किटेक्चरल बेमेल और सुरक्षा दोष पेश करता है। जो पुल अनुरोध बिना +समीक्षा वाली कच्ची एआई आउटपुट प्रतीत होंगे, उन्हें maintainer के समय का सम्मान करने के लिए बंद कर +दिया जाएगा। + ## अतिरिक्त दिशानिर्देश ### कमिट संदेश diff --git a/website/i18n/hi/docusaurus-plugin-content-pages/contributing.js b/website/i18n/hi/docusaurus-plugin-content-pages/contributing.js index 9e65da078..ea91d6f2d 100644 --- a/website/i18n/hi/docusaurus-plugin-content-pages/contributing.js +++ b/website/i18n/hi/docusaurus-plugin-content-pages/contributing.js @@ -132,6 +132,15 @@ export default function Contributing() { स्रोत देखें GitHub रिपॉज़िटरी + + योगदान नियम + CONTRIBUTING.md + प्रायोजक बनें diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/developer/getting-started/submitting-changes.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/developer/getting-started/submitting-changes.md index cffc4b527..a0b6ebe9e 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/developer/getting-started/submitting-changes.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/developer/getting-started/submitting-changes.md @@ -49,11 +49,21 @@ git rebase upstream/main git push origin feature/seu-nome-de-feature ``` -2. Crie um pull request no GitHub com: - - Um título claro descrevendo a mudança - - Uma descrição detalhada do que você mudou e por quê +2. Crie um pull request no GitHub e preencha o modelo de pull request: + - Um título claro e uma descrição da mudança e do problema que ela resolve + - Por que você escolheu sua abordagem arquitetônica específica e quais alternativas descartou + - Os passos exatos que você usou para testar a mudança localmente - Referência a quaisquer issues relacionados - Screenshots se a mudança afeta a UI + - Todas as caixas da checklist do modelo marcadas + + +:::warning[O modelo é obrigatório] +Pull requests que não preenchem o modelo são fechados automaticamente por um bot. Edite a descrição +do pull request para responder cada seção e marcar cada caixa; o pull request é reaberto +automaticamente. +::: + ## Processo de Code Review @@ -71,6 +81,16 @@ Pull requests são merged quando: - [ ] Têm atualizações de documentação se necessário - [ ] São aprovados por pelo menos um mantenedor +## Código Assistido por IA + +Nós damos boas-vindas ao uso de ferramentas de IA para ajudar a escrever código. No entanto, você é +estritamente responsável pelo código que envia. Você DEVE entendê-lo completamente e ser capaz de +explicar as escolhas arquitetônicas. + +Código gerado por IA frequentemente introduz bugs sutis, incompatibilidades arquitetônicas e falhas +de segurança. Pull requests que parecerem saída bruta de IA sem revisão serão fechados para +respeitar o tempo dos mantenedores. + ## Diretrizes Adicionais ### Mensagens de Commit diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-pages/contributing.js b/website/i18n/pt-BR/docusaurus-plugin-content-pages/contributing.js index a02b33e01..ec52bf2ca 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-pages/contributing.js +++ b/website/i18n/pt-BR/docusaurus-plugin-content-pages/contributing.js @@ -130,6 +130,17 @@ export default function Contributing() { Explorar o código-fonte Repositório GitHub + + + Regras de contribuição + + CONTRIBUTING.md + Torne-se um Patrocinador diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/developer/getting-started/submitting-changes.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/developer/getting-started/submitting-changes.md index b6485f3f2..3558ebd91 100644 --- a/website/i18n/uk/docusaurus-plugin-content-docs/current/developer/getting-started/submitting-changes.md +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/developer/getting-started/submitting-changes.md @@ -49,11 +49,21 @@ git rebase upstream/main git push origin feature/your-feature-name ``` -2. Створіть pull request на GitHub з: - - Чіткою назвою, що описує зміну - - Детальним описом того, що ви змінили і чому - - Посиланням на будь-які пов'язані issues - - Скріншотами якщо зміна впливає на UI +2. Створіть pull request на GitHub і заповніть шаблон pull request: + - Чітку назву та опис зміни і проблеми, яку вона вирішує + - Чому ви обрали саме такий архітектурний підхід і які альтернативи відхилили + - Точні кроки, якими ви тестували зміну локально + - Посилання на будь-які пов'язані issues + - Скріншоти якщо зміна впливає на UI + - Усі прапорці чекліста шаблона відмічені + + +:::warning[Шаблон є обов'язковим] +Pull requests, які не заповнюють шаблон, автоматично закриваються ботом. Відредагуйте опис pull +request так, щоб кожен розділ був заповнений і кожен прапорець відмічений — після цього pull +request автоматично відкривається знову. +::: + ## Процес код-рев'ю @@ -71,6 +81,16 @@ Pull requests зливаються коли вони: - [ ] Мають оновлення документації за потреби - [ ] Затверджені принаймні одним мейнтейнером +## Код за допомогою ШІ + +Ми вітаємо використання інструментів ШІ для написання коду. Однак ви несете повну відповідальність +за код, який надсилаєте. Ви ПОВИННІ повністю його розуміти і бути спроможними пояснити архітектурні +рішення. + +Код, згенерований ШІ, часто містить тонкі помилки, архітектурні невідповідності та вразливості +безпеки. Pull requests, що виглядають як сирі неперевірені відповіді ШІ, буде закрито, щоб заощадити +час мейнтейнерів. + ## Додаткові вказівки ### Повідомлення комітів diff --git a/website/i18n/uk/docusaurus-plugin-content-pages/contributing.js b/website/i18n/uk/docusaurus-plugin-content-pages/contributing.js index a6893ece1..4a2b028d5 100644 --- a/website/i18n/uk/docusaurus-plugin-content-pages/contributing.js +++ b/website/i18n/uk/docusaurus-plugin-content-pages/contributing.js @@ -131,6 +131,17 @@ export default function Contributing() { Переглянути вихідний код Репозиторій GitHub + + + Правила внеску + + CONTRIBUTING.md + Стати спонсором diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/developer/getting-started/submitting-changes.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/developer/getting-started/submitting-changes.md index 3437fe227..ec17c9e72 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/developer/getting-started/submitting-changes.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/developer/getting-started/submitting-changes.md @@ -49,11 +49,20 @@ git rebase upstream/main git push origin feature/your-feature-name ``` -2. 在 GitHub 上创建 pull request,包含: - - 描述更改的清晰标题 - - 详细说明您更改了什么以及为什么 +2. 在 GitHub 上创建 pull request 并填写 pull request 模板: + - 清晰的标题,以及说明更改内容及其所解决的问题 + - 为什么选择这种特定的架构方案,以及否决了哪些备选方案 + - 本地测试该更改的确切步骤 - 引用任何相关问题 - 如果更改影响 UI,附上截图 + - 勾选模板清单中的所有复选框 + + +:::warning[模板强制执行] +未填写模板的 pull request 会被机器人自动关闭。编辑 pull request 描述,回答每个部分并勾选每个 +复选框后,pull request 会自动重新打开。 +::: + ## 代码审查流程 @@ -71,6 +80,13 @@ Pull request 在以下情况下合并: - [ ] 如需要更新文档 - [ ] 至少获得一位维护者的批准 +## AI 辅助代码 + +我们欢迎使用 AI 工具来辅助编写代码。但是,你提交的代码完全由你负责。你必须完全理解这些代码,并且能够解释其中的架构决策。 + +AI 生成的代码常常引入隐蔽的 bug、架构不匹配和安全漏洞。看起来是未经审查的原始 AI 输出的 pull +request 将被关闭,以尊重维护者的时间。 + ## 其他指南 ### 提交消息 diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-pages/contributing.js b/website/i18n/zh-CN/docusaurus-plugin-content-pages/contributing.js index d30b0a17f..bd68ea1f2 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-pages/contributing.js +++ b/website/i18n/zh-CN/docusaurus-plugin-content-pages/contributing.js @@ -130,6 +130,15 @@ export default function Contributing() { 浏览源代码 GitHub 仓库 + + 贡献规则 + CONTRIBUTING.md + 成为赞助商 diff --git a/website/src/pages/contributing.js b/website/src/pages/contributing.js index b4c4e07d9..2368bc8ad 100644 --- a/website/src/pages/contributing.js +++ b/website/src/pages/contributing.js @@ -141,6 +141,17 @@ export default function Contributing() { Browse source GitHub repository + + + Contribution rules + + CONTRIBUTING.md + Become a Sponsor From 988591f5a037f44a042bb9712e1fb5ccdfc96a93 Mon Sep 17 00:00:00 2001 From: Samuel Abels Date: Thu, 15 Oct 2026 13:13:18 +0200 Subject: [PATCH 088/104] autoformat --- rayforge/ui_gtk/mainwindow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rayforge/ui_gtk/mainwindow.py b/rayforge/ui_gtk/mainwindow.py index dad4a3aa4..c4f6f0972 100644 --- a/rayforge/ui_gtk/mainwindow.py +++ b/rayforge/ui_gtk/mainwindow.py @@ -18,7 +18,6 @@ from ..core.registration import call_registration_hooks from ..core.undo import Command, HistoryManager from ..doceditor.editor import DocEditor -from .doceditor.frame_popover import FramePopover from ..machine.cmd import MachineCmd from ..machine.driver.driver import DeviceState, DeviceStatus from ..machine.driver.dummy import NoDeviceDriver @@ -46,6 +45,7 @@ from .debug_log_dialog import DebugLogDialog from .doceditor import file_dialogs from .doceditor.bottom_panel import BottomPanel +from .doceditor.frame_popover import FramePopover from .doceditor.import_handler import start_interactive_import from .doceditor.item_properties import DocItemPropertiesWidget from .doceditor.missing_features_dialog import MissingFeaturesDialog From 6e25e14833229f3ea96579eb650cf09bf0da7dfc Mon Sep 17 00:00:00 2001 From: Samuel Abels Date: Thu, 15 Oct 2026 13:34:53 +0200 Subject: [PATCH 089/104] docs: screenshot the Perforation post-processor and its result Add two screenshot targets: operations:perforation loads a new perforation.ryp asset (a rectangle and a circle on a plywood stock, Perforation enabled at 1.0 mm cut / 0.5 mm skip) and captures a close-up of the dashed toolpath; step-settings:contour:post:perforation opens the post-processing page with the Perforation group enabled and expanded. Regenerate step-settings-contour-post.webp so it includes the new Perforation row. --- scripts/screenshot/cli.py | 2 + scripts/screenshot/perforation.py | 139 ++++++++++++++++++ .../screenshot/step_settings_perforation.py | 109 ++++++++++++++ tests/assets/perforation.ryp | Bin 0 -> 6101 bytes .../screenshots/operations-perforation.webp | Bin 0 -> 158598 bytes ...tep-settings-contour-post-perforation.webp | Bin 0 -> 25618 bytes .../step-settings-contour-post.webp | Bin 20586 -> 21710 bytes 7 files changed, 250 insertions(+) create mode 100644 scripts/screenshot/perforation.py create mode 100644 scripts/screenshot/step_settings_perforation.py create mode 100644 tests/assets/perforation.ryp create mode 100644 website/static/screenshots/operations-perforation.webp create mode 100644 website/static/screenshots/step-settings-contour-post-perforation.webp diff --git a/scripts/screenshot/cli.py b/scripts/screenshot/cli.py index 025ffbe68..2ead7f2b6 100755 --- a/scripts/screenshot/cli.py +++ b/scripts/screenshot/cli.py @@ -124,6 +124,7 @@ "addons:sketcher:array:circular": "sketcher_array", "addons:sketcher:array:curve-along": "sketcher_array", "material-test": "material_test", + "operations:perforation": "perforation", "operations:wavefront": "wavefront", "recipe-editor:general": "recipe_editor_general", "recipe-editor:applicability": "recipe_editor_applicability", @@ -135,6 +136,7 @@ "step-settings:contour:general": "step_settings", "step-settings:contour:laser": "step_settings", "step-settings:contour:post": "step_settings", + "step-settings:contour:post:perforation": "step_settings_perforation", "step-settings:engrave:general:constant_power": "step_settings", "step-settings:engrave:general:dither": "step_settings", "step-settings:engrave:general:multi_pass": "step_settings", diff --git a/scripts/screenshot/perforation.py b/scripts/screenshot/perforation.py new file mode 100644 index 000000000..43504ef99 --- /dev/null +++ b/scripts/screenshot/perforation.py @@ -0,0 +1,139 @@ +""" +Screenshot: Perforation post-processor applied to a contour. + +Usage: pixi run screenshot operations:perforation +""" + +import logging +import time + +from utils import ( + clear_window_subtitle, + load_project, + restore_config, + run_on_main_thread, + set_window_size, + take_region_screenshot, + wait_for_settled, +) + +from rayforge.core.workpiece import WorkPiece +from rayforge.uiscript import app, win + +logger = logging.getLogger(__name__) + +TARGET_PX_PER_MM = 26.0 +CROP_SIZE = (1200, 800) + + +def _content_boxes() -> list[tuple[float, float, float, float]]: + items = [ + item + for layer in win.doc_editor.doc.layers + for item in layer.children + if isinstance(item, WorkPiece) + ] + items.extend(win.doc_editor.doc.stock_items) + return [item.bbox for item in items if item.size != (0.0, 0.0)] + + +def frame_closeup() -> tuple[float, float] | None: + """Zoom in on the workpiece so the dash pattern is clearly visible. + + Centers the view on a point inside the workpiece and returns the + focused world position, or None when there is nothing to frame. + """ + + def _frame() -> tuple[float, float] | None: + boxes = _content_boxes() + if not boxes: + logger.warning("No content to frame") + return None + min_x = min(b[0] for b in boxes) + min_y = min(b[1] for b in boxes) + max_x = max(b[0] + b[2] for b in boxes) + max_y = max(b[1] + b[3] for b in boxes) + + work_boxes = [ + b + for layer in win.doc_editor.doc.layers + for b in [ + item.bbox + for item in layer.children + if isinstance(item, WorkPiece) + ] + ] + if work_boxes: + box = work_boxes[0] + else: + box = (min_x, min_y, max_x - min_x, max_y - min_y) + + focus_x = box[0] + 0.79 * box[2] + focus_y = box[1] + 0.5 * box[3] + + scale_x, scale_y = win.surface.get_view_scale() + px_per_mm = max((scale_x + scale_y) / 2.0, 1e-9) + zoom = win.surface.zoom_level * TARGET_PX_PER_MM / px_per_mm + win.surface.set_zoom(max(zoom, 0.01)) + + for _pass in range(2): + pos = win.surface.view_transform.transform_point(focus_x, focus_y) + dx = win.surface.get_width() / 2.0 - pos[0] + dy = win.surface.get_height() / 2.0 - pos[1] + scale_x, scale_y = win.surface.get_view_scale() + win.surface.set_pan( + win.surface.pan_x_mm - dx / scale_x, + win.surface.pan_y_mm + dy / scale_y, + ) + return focus_x, focus_y + + return run_on_main_thread(_frame) + + +def closeup_region(focus: tuple[float, float]) -> tuple[int, int, int, int]: + """Return the main-window region around the focused point.""" + + def _region() -> tuple[int, int, int, int]: + pos = win.surface.view_transform.transform_point(*focus) + ox, oy = win.surface.translate_coordinates(win, 0, 0) + cx, cy = ox + pos[0], oy + pos[1] + w, h = CROP_SIZE + return ( + int(cx - w / 2), + int(cy - h / 2), + int(cx + w / 2), + int(cy + h / 2), + ) + + return run_on_main_thread(_region) + + +@restore_config +def main(): + set_window_size(win, 2400, 1650) + + load_project(win, "perforation.ryp") + logger.info("Waiting for document to settle...") + if not wait_for_settled(win, timeout=30): + logger.error("Document did not settle in time") + app.quit_idle() + return + logger.info("Document settled") + + focus = frame_closeup() + if focus is None: + app.quit_idle() + return + time.sleep(0.5) + + clear_window_subtitle(win) + time.sleep(0.25) + + logger.info("Taking close-up screenshot: operations-perforation.webp") + take_region_screenshot(win, closeup_region(focus)) + + time.sleep(0.25) + app.quit_idle() + + +main() diff --git a/scripts/screenshot/step_settings_perforation.py b/scripts/screenshot/step_settings_perforation.py new file mode 100644 index 000000000..b5d557785 --- /dev/null +++ b/scripts/screenshot/step_settings_perforation.py @@ -0,0 +1,109 @@ +"""Screenshot: Contour post-processing page with Perforation expanded.""" + +import logging +import time + +from gi.repository import Adw +from utils import ( + find_step_by_type, + get_target, + load_project, + open_step_settings, + restore_config, + run_on_main_thread, + set_window_size, + take_screenshot, +) + +from rayforge.uiscript import app, win + +logger = logging.getLogger(__name__) + +TARGET = get_target("step-settings:contour:post:perforation") + +TRANSFORMER_NAME = "PerforationTransformer" + + +def find_expander_row(widget, title: str) -> Adw.ExpanderRow | None: + """Find the expander row with the given title in the widget tree.""" + if isinstance(widget, Adw.ExpanderRow) and widget.get_title() == title: + return widget + child = widget.get_first_child() + while child is not None: + found = find_expander_row(child, title) + if found is not None: + return found + child = child.get_next_sibling() + return None + + +def find_post_page(widget): + """Find the post-processing page in the widget tree.""" + if hasattr(widget, "_group_dicts"): + return widget + child = widget.get_first_child() + while child is not None: + found = find_post_page(child) + if found is not None: + return found + child = child.get_next_sibling() + return None + + +def stage_perforation_group(dialog) -> bool: + """Enable the Perforation transformer and expand its settings.""" + + def _stage() -> bool: + page = find_post_page(dialog) + if page is None: + return False + for group, t_dict in page._group_dicts.items(): + if t_dict.get("name") != TRANSFORMER_NAME: + continue + title = group.get_title() + group.enable_switch.set_active(True) + expander = find_expander_row(page, title) + if expander is not None: + expander.set_expanded(True) + return True + return False + + staged = run_on_main_thread(_stage) + if not staged: + logger.error(f"No {TRANSFORMER_NAME} found on the post page") + return staged + + +@restore_config +def main(): + set_window_size(win, 2400, 1650) + + load_project(win, "allsteps.ryp") + time.sleep(0.25) + + step_type, page, _mode = "contour", "post-processing", None + logger.info(f"Target: {TARGET} -> type={step_type}, page={page}") + + found = find_step_by_type(win, step_type) + if found is None or found[0] is None: + logger.error(f"No step found with type: {step_type}") + app.quit_idle() + return + + step, step_index = found + logger.info(f"Found step: {step.name} at index {step_index}") + + dialog = open_step_settings(win, step_index=step_index, page=page) + time.sleep(0.5) + + if not stage_perforation_group(dialog): + app.quit_idle() + return + time.sleep(0.5) + + take_screenshot() + time.sleep(0.25) + app.quit_idle() + + +main() diff --git a/tests/assets/perforation.ryp b/tests/assets/perforation.ryp new file mode 100644 index 0000000000000000000000000000000000000000..58ef01d2e3deaae6572e43013f0cdf652c52a06d GIT binary patch literal 6101 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 literal 0 HcmV?d00001 diff --git a/website/static/screenshots/operations-perforation.webp b/website/static/screenshots/operations-perforation.webp new file mode 100644 index 0000000000000000000000000000000000000000..2dc33170508d94e481843d36b214267d0c488931 GIT binary patch literal 158598 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 Nrs)5{wKVbH;oqfJ8Cn1U From 972370d0d924e9006d79a656da57fdb42efe24dc Mon Sep 17 00:00:00 2001 From: Samuel Abels Date: Thu, 15 Oct 2026 13:35:35 +0200 Subject: [PATCH 090/104] docs: rework the Perforation page prose and embed the screenshots Turn the bullet-heavy page into flowing prose in English and all translations, explain where the post-processor lives and how the pattern behaves, and show both the settings UI and the resulting dashed cut on the canvas. --- website/docs/features/perforation.md | 68 +++++++++++------ .../current/features/perforation.md | 74 ++++++++++++------- .../current/features/perforation.md | 74 +++++++++++++------ .../current/features/perforation.md | 74 +++++++++++++------ .../current/features/perforation.md | 64 ++++++++++------ .../current/features/perforation.md | 70 ++++++++++++------ .../current/features/perforation.md | 68 +++++++++++------ .../current/features/perforation.md | 43 +++++++---- 8 files changed, 357 insertions(+), 178 deletions(-) diff --git a/website/docs/features/perforation.md b/website/docs/features/perforation.md index 4b3c58937..14cdf970b 100644 --- a/website/docs/features/perforation.md +++ b/website/docs/features/perforation.md @@ -6,38 +6,60 @@ description: # Perforation -The **Perforation** post-processor turns a continuous cut into a dashed one. The laser fires for the -**Cut Length**, then travels with the laser off for the **Skip Length**, and repeats this along -every contour of the workpiece. +Not every cut is meant to free a part from the material. Some lines are there so the material can +fold along them, tear apart cleanly, or show where the needle goes. That is what the **Perforation** +post-processor is for: it turns a continuous cut into a dashed one. Instead of keeping the laser on +from start to finish, it fires for the **Cut Length**, travels the **Skip Length** with the laser +off, and repeats that rhythm along every contour of the workpiece. What remains is a line of small +cuts that holds the material together — until you want it to give way. -## When to Use It +![The Perforation settings on the post-processing page of a Contour step](/screenshots/step-settings-contour-post-perforation.webp) -- Fold lines in card stock and corrugated cardboard -- Tear-off lines (tickets, coupons, packaging) -- Stitch holes in leather -- Dashed lines for decoration +## Typical Uses -## Settings +Whenever a line should weaken the material without cutting it free, a perforation cut is worth +considering. The classics: -Perforation is available in the post-processing settings of **Contour** operations. It is off by -default. +- **Fold lines** in card stock and corrugated cardboard fold crisply along a dashed cut — think + boxes, cards and pop-up designs. +- **Tear-off lines** let tickets, coupons and packaging open without scissors. +- **Stitch holes** in leather mark the path of the needle before sewing. +- **Dashed outlines** are a decorative effect in their own right, on wood, paper and acrylic. -- **Cut Length**: the distance the laser fires before each gap. -- **Skip Length**: the distance travelled with the laser off between two cuts. +## Enabling Perforation -The pattern is measured along each contour of the workpiece and starts again at the start of every -contour, beginning with a full cut. A contour shorter than one cut plus one skip is cut in full. +Perforation lives on the **Post Processing** page of a [Contour](operations/contour) step's +settings, alongside the other post-processors. It is off by default — flip the switch and two values +appear: + +- **Cut Length** is how far the laser stays on before each gap. +- **Skip Length** is how far it travels with the laser off between two cuts. + +The pattern starts at the beginning of each contour with a full cut and restarts from there on every +contour, so all dashed lines on the workpiece begin the same way. A contour too short to fit even +one cut and one skip is simply cut in one piece. + +## The Result + +On the canvas, the dashed line replaces the solid cut outline — every gap in the toolpath is a gap +in the finished part. + +![A rectangle and a circle cut as dashed lines into a sheet of plywood](/screenshots/operations-perforation.webp) ## Tips -- For a fold line that should not cut through, start with a skip length about as long as the cut - length and lower the power. -- Very short lengths (a few tenths of a millimetre) make the laser switch on and off rapidly, which - lowers the effective power. -- Perforation works together with [Holding Tabs](holding-tabs), [Lead-In/Out](lead-in-out) and - [Multi-Pass](multi-pass). +For a fold line that must not cut through, start with a skip length about as long as the cut length +and lower the power — a shallow groove folds just as well as a deep dashed cut, and the underside +stays clean. + +Very short lengths (a few tenths of a millimetre) make the laser switch on and off in rapid +succession, which lowers the effective power and can leave uneven marks. If the pattern looks +inconsistent, try longer lengths. + +Perforation plays well with the rest of the post-processing stack: [Holding Tabs](holding-tabs), +[Lead-In/Out](lead-in-out) and [Multi-Pass](multi-pass) all keep working as usual. ## Related Pages -- [Contour Cutting](operations/contour) - The cutting operation that uses perforation -- [Holding Tabs](holding-tabs) - Gaps placed by hand to keep parts attached +- [Contour Cutting](operations/contour) — the cutting operation this post-processor belongs to +- [Holding Tabs](holding-tabs) — gaps placed by hand to keep parts attached diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/features/perforation.md b/website/i18n/de/docusaurus-plugin-content-docs/current/features/perforation.md index dbeffde48..4405bc238 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/features/perforation.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/features/perforation.md @@ -6,40 +6,64 @@ description: # Perforation -Der Nachbearbeitungsschritt **Perforation** macht aus einem durchgehenden Schnitt einen -gestrichelten. Der Laser schneidet über die **Schnittlänge**, fährt dann mit ausgeschaltetem Laser -über die **Auslasslänge** und wiederholt das entlang jeder Kontur des Werkstücks. +Nicht jeder Schnitt soll ein Teil aus dem Material lösen. Manche Linien sind da, damit sich das +Material entlang ihnen falten lässt, sauber abreißt oder den Weg der Nadel vorgezeichnet. Dafür gibt +es den Nachbearbeitungsschritt **Perforation**: Er macht aus einem durchgehenden Schnitt einen +gestrichelten. Statt den Laser von Anfang bis Ende eingeschaltet zu lassen, feuert er über die +**Schnittlänge**, fährt die **Auslasslänge** mit ausgeschaltetem Laser zurück und wiederholt diesen +Rhythmus entlang jeder Kontur des Werkstücks. Übrig bleibt eine Reihe kleiner Schnitte, die das +Material zusammenhalten — bis es nachgeben soll. -## Wann verwenden +![Die Perforation-Einstellungen auf der Nachbearbeitung-Seite eines Kontur-Schritts](/screenshots/step-settings-contour-post-perforation.webp) -- Falzlinien in Karton und Wellpappe -- Abreißlinien (Tickets, Gutscheine, Verpackungen) -- Nählöcher in Leder -- Dekorative gestrichelte Linien +## Beispielhafte Anwendungsfälle -## Einstellungen +Überall dort, wo eine Linie das Material schwächen soll, ohne es zu durchtrennen, lohnt sich ein +perforierter Schnitt. Die klassischen Fälle: -Perforation ist in den Nachbearbeitungseinstellungen von **Kontur**-Operationen verfügbar und -standardmäßig ausgeschaltet. +- **Falzlinien** in Karton und Wellpappe brechen entlang einer gestrichelten Linie sauber — etwa bei + Schachteln, Karten und Pop-up-Designs. +- **Abreißlinien** öffnen Tickets, Gutscheine und Verpackungen ganz ohne Schere. +- **Nählöcher** in Leder markieren den Weg der Nadel, bevor genäht wird. +- **Gestrichelte Konturen** sind darüber hinaus ein dekorativer Effekt — auf Holz, Papier und Acryl. -- **Schnittlänge**: die Strecke, die der Laser vor jeder Lücke schneidet. -- **Auslasslänge**: die Strecke, die zwischen zwei Schnitten mit ausgeschaltetem Laser gefahren - wird. +## Perforation aktivieren -Das Muster wird entlang jeder Kontur des Werkstücks gemessen und beginnt am Anfang jeder Kontur neu, -immer mit einem vollen Schnitt. Eine Kontur, die kürzer ist als ein Schnitt plus eine Lücke, wird -vollständig geschnitten. +Perforation findet sich auf der Seite **Nachbearbeitung** der Einstellungen eines +[Kontur](operations/contour)-Schritts, neben den übrigen Nachbearbeitungsschritten. Sie ist +standardmäßig ausgeschaltet — sobald der Schalter umgelegt wird, erscheinen zwei Werte: + +- Die **Schnittlänge** gibt an, wie weit der Laser vor jeder Lücke eingeschaltet bleibt. +- Die **Auslasslänge** ist die Strecke, die zwischen zwei Schnitten mit ausgeschaltetem Laser + zurückgelegt wird. + +Das Muster beginnt am Anfang jeder Kontur mit einem vollen Schnitt und beginnt auf jeder Kontur neu, +sodass alle gestrichelten Linien des Werkstücks gleich ansetzen. Eine Kontur, zu klein für auch nur +einen Schnitt und eine Lücke, wird einfach am Stück geschnitten. + +## Das Ergebnis + +Auf der Arbeitsfläche ersetzt die gestrichelte Linie die durchgehende Schnittkontur — jede Lücke im +Werkzeugpfad ist auch eine Lücke im fertigen Teil. + +![Ein Rechteck und ein Kreis werden als gestrichelte Linien in eine Birkenplywood-Platte geschnitten](/screenshots/operations-perforation.webp) ## Tipps -- Für eine Falzlinie, die nicht durchschneiden soll, mit etwa gleich langer Schnitt- und - Auslasslänge beginnen und die Leistung senken. -- Sehr kurze Längen (wenige Zehntelmillimeter) lassen den Laser schnell ein- und ausschalten, was - die effektive Leistung verringert. -- Perforation funktioniert zusammen mit [Halte-Laschen](holding-tabs), [Lead-In/Out](lead-in-out) - und [Mehrfachdurchgang](multi-pass). +Für eine Falzlinie, die nicht durchtrennen soll, beginnt man am besten mit einer Auslasslänge von +etwa der Schnittlänge und senkt die Leistung — eine flache Rille faltet genauso gut wie ein tiefer +gestrichelter Schnitt, und die Unterseite bleibt sauber. + +Sehr kurze Längen (wenige Zehntelmillimeter) lassen den Laser in schneller Folge ein- und +ausschalten, was die effektive Leistung verringert und ungleichmäßige Spuren hinterlassen kann. +Wirkt das Muster uneinheitlich, helfen längere Werte. + +Perforation lässt sich gut mit dem Rest der Nachbearbeitung kombinieren: +[Halte-Laschen](holding-tabs), [Einlauf / Auslauf](lead-in-out) und [Mehrfach-Durchgang](multi-pass) +funktionieren wie gewohnt. ## Verwandte Seiten -- [Konturschneiden](operations/contour) - Die Schneidoperation, die Perforation verwendet -- [Halte-Laschen](holding-tabs) - Von Hand gesetzte Lücken, die Teile festhalten +- [Kontur-Schneiden](operations/contour) — die Schneidoperation, zu der dieser + Nachbearbeitungsschritt gehört +- [Halte-Laschen](holding-tabs) — von Hand gesetzte Lücken, die Teile festhalten diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/features/perforation.md b/website/i18n/es/docusaurus-plugin-content-docs/current/features/perforation.md index 42370afd9..26e49563a 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/features/perforation.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/features/perforation.md @@ -6,39 +6,65 @@ description: # Perforación -El posprocesador **Perforación** convierte un corte continuo en uno discontinuo. El láser dispara -durante la **Longitud de corte**, luego se desplaza con el láser apagado durante la **Longitud de -salto**, y repite esto a lo largo de cada contorno de la pieza. +No todos los cortes sirven para separar una pieza del material. Hay líneas que existen para que el +material se pliegue sobre ellas, se rasgue limpiamente o marque el camino de la aguja. Para eso está +el posprocesador **Perforación**: convierte un corte continuo en uno discontinuo. En lugar de +mantener el láser encendido de principio a fin, dispara durante la **Longitud de corte**, recorre la +**Longitud de salto** con el láser apagado, y repite ese ritmo a lo largo de cada contorno de la +pieza. Lo que queda es una línea de cortes pequeños que mantiene unido el material — hasta que +quieras que ceda. -## Cuándo usarlo +![Los ajustes de Perforación en la página de posprocesado de un paso de Contorno](/screenshots/step-settings-contour-post-perforation.webp) -- Líneas de pliegue en cartulina y cartón ondulado -- Líneas de rasgado (entradas, cupones, embalajes) -- Agujeros de costura en cuero -- Líneas discontinuas decorativas +## Algunos usos típicos -## Ajustes +Allí donde una línea deba debilitar el material sin cortarlo por completo, vale la pena un corte +perforado. Los casos clásicos: -La perforación está disponible en los ajustes de posprocesado de las operaciones de **Contorno**. -Está desactivada por defecto. +- **Líneas de pliegue** en cartulina y cartón ondulado se pliegan con precisión sobre un corte + discontinuo: cajas, tarjetas y diseños pop-up, por ejemplo. +- **Líneas de rasgado** abren entradas, cupones y envases sin necesidad de tijeras. +- **Agujeros de costura** en cuero marcan el camino de la aguja antes de coser. +- **Contornos discontinuos** son además un efecto decorativo en sí mismos, sobre madera, papel o + acrílico. -- **Longitud de corte**: la distancia que dispara el láser antes de cada hueco. -- **Longitud de salto**: la distancia recorrida con el láser apagado entre dos cortes. +## Activar la perforación -El patrón se mide a lo largo de cada contorno de la pieza y vuelve a empezar al inicio de cada -contorno, siempre con un corte completo. Un contorno más corto que un corte más un salto se corta -entero. +La perforación se encuentra en la página **Posprocesado** de los ajustes de un paso de +[Contorno](operations/contour), junto a los demás posprocesadores. Viene desactivada por defecto; al +activar el interruptor aparecen dos valores: + +- La **Longitud de corte** es cuánto permanece encendido el láser antes de cada hueco. +- La **Longitud de salto** es la distancia recorrida con el láser apagado entre dos cortes. + +El patrón empieza al inicio de cada contorno con un corte completo y vuelve a empezar en cada +contorno, de modo que todas las líneas discontinuas de la pieza comienzan igual. Un contorno +demasiado corto para un solo corte y un salto simplemente se corta entero. + +## El resultado + +En el lienzo, la línea discontinua sustituye al contorno de corte sólido: cada hueco del trazado es +también un hueco en la pieza terminada. + +![Un rectángulo y un círculo se cortan como líneas discontinuas en una plancha de madera +contrachapada](/screenshots/operations-perforation.webp) ## Consejos -- Para una línea de pliegue que no debe atravesar el material, empieza con una longitud de salto - similar a la de corte y baja la potencia. -- Longitudes muy cortas (unas décimas de milímetro) hacen que el láser se encienda y apague - rápidamente, lo que reduce la potencia efectiva. -- La perforación funciona junto con [Pestañas de sujeción](holding-tabs), - [Entrada/Salida](lead-in-out) y [Multipasada](multi-pass). +Para una línea de pliegue que no debe atravesar el material, empieza con una longitud de salto +parecida a la de corte y baja la potencia: un surco poco profundo pliega igual de bien que un corte +discontinuo profundo, y el reverso queda limpio. + +Las longitudes muy cortas (unas décimas de milímetro) hacen que el láser se encienda y apague a toda +velocidad, lo que reduce la potencia efectiva y puede dejar marcas irregulares. Si el patrón se ve +desigual, prueba con longitudes mayores. + +La perforación se combina bien con el resto del posprocesado: las +[Pestañas de Sujeción](holding-tabs), la [Entrada / Salida](lead-in-out) y la +[Multi-Pasada](multi-pass) siguen funcionando como siempre. ## Páginas relacionadas -- [Corte de contorno](operations/contour) - La operación de corte que usa la perforación -- [Pestañas de sujeción](holding-tabs) - Huecos colocados a mano para mantener las piezas unidas +- [Corte de Contorno](operations/contour) — la operación de corte a la que pertenece este + posprocesador +- [Pestañas de Sujeción](holding-tabs) — huecos colocados a mano para mantener las piezas unidas diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/perforation.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/perforation.md index 2933115d8..0ba276d24 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/perforation.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/perforation.md @@ -6,40 +6,66 @@ description: # Perforation -Le post-traitement **Perforation** transforme une découpe continue en découpe en pointillés. Le -laser tire sur la **Longueur de coupe**, puis se déplace laser éteint sur la **Longueur de saut**, -et répète cela le long de chaque contour de la pièce. +Tous les traits ne servent pas à détacher une pièce du matériau. Certains existent pour que le +matériau se plie le long d'eux, se déchire proprement, ou pour marquer le passage de l'aiguille. +C'est le rôle du post-traitement **Perforation** : il transforme une coupe continue en coupe en +pointillés. Au lieu de laisser le laser allumé du début à la fin, il tire sur la **Longueur de +coupe**, parcourt la **Longueur de saut** laser éteint, et répète ce rythme le long de chaque +contour de la pièce. Il reste une ligne de petites coupes qui maintient le matériau — jusqu'à ce que +vous décidiez de le plier ou de le déchirer. -## Quand l'utiliser +![Les réglages de la Perforation sur la page de post-traitement d'une étape de Contour](/screenshots/step-settings-contour-post-perforation.webp) -- Lignes de pliage dans le carton et le carton ondulé -- Lignes prédécoupées (billets, coupons, emballages) -- Trous de couture dans le cuir -- Pointillés décoratifs +## Quelques cas d'usage -## Réglages +Chaque fois qu'un trait doit affaiblir le matériau sans le couper, une coupe perforée a du sens. Les +grands classiques : -La perforation est disponible dans les réglages de post-traitement des opérations **Contour**. Elle -est désactivée par défaut. +- **Les lignes de pliage** dans le carton et le carton ondulé se plient net le long d'une coupe en + pointillés — boîtes, cartes et créations pop-up, par exemple. +- **Les lignes prédécoupées** ouvrent billets, bons et emballages sans ciseaux. +- **Les trous de couture** dans le cuir marquent le passage de l'aiguille avant la couture. +- **Les contours en pointillés** sont en eux-mêmes un effet décoratif, sur le bois, le papier ou + l'acrylique. -- **Longueur de coupe** : la distance sur laquelle le laser tire avant chaque interruption. -- **Longueur de saut** : la distance parcourue laser éteint entre deux coupes. +## Activer la perforation -Le motif est mesuré le long de chaque contour de la pièce et recommence au début de chaque contour, -toujours par une coupe complète. Un contour plus court qu'une coupe plus un saut est découpé en -entier. +La perforation se trouve sur la page **Post-traitement** des réglages d'une étape de +[Contour](operations/contour), aux côtés des autres post-traitements. Elle est désactivée par défaut +; dès que vous basculez l'interrupteur, deux valeurs apparaissent : + +- La **Longueur de coupe** est la distance pendant laquelle le laser reste allumé avant chaque + interruption. +- La **Longueur de saut** est la distance parcourue laser éteint entre deux coupes. + +Le motif recommence au début de chaque contour, toujours par une coupe complète, pour que toutes les +lignes en pointillés de la pièce démarrent de la même façon. Un contour trop court pour ne serait-ce +qu'une coupe et un saut est simplement découpé d'un seul tenant. + +## Le résultat + +Sur le canevas, la ligne en pointillés remplace le contour de coupe plein : chaque interruption du +tracé sera aussi une interruption dans la pièce finie. + +![Un rectangle et un cercle découpés en pointillés dans une planche de contreplaqué](/screenshots/operations-perforation.webp) ## Conseils -- Pour une ligne de pliage qui ne doit pas traverser, commencez avec une longueur de saut proche de - la longueur de coupe et baissez la puissance. -- Des longueurs très courtes (quelques dixièmes de millimètre) font s'allumer et s'éteindre le laser - rapidement, ce qui réduit la puissance effective. -- La perforation fonctionne avec les [Ponts de maintien](holding-tabs), - l'[Entrée/Sortie](lead-in-out) et les [Passes multiples](multi-pass). +Pour une ligne de pliage qui ne doit pas traverser le matériau, commencez avec une longueur de saut +proche de la longueur de coupe et baissez la puissance : une rainure peu profonde plie aussi bien +qu'une coupe pointillée profonde, et l'envers reste propre. + +Les longueurs très courtes (quelques dixièmes de millimètre) font allumer et éteindre le laser à +grande cadence, ce qui réduit la puissance effective et peut laisser des traces irrégulières. Si le +motif paraît inégal, essayez des valeurs plus longues. + +La perforation se combine bien avec le reste du post-traitement : les +[Ponts de Maintien](holding-tabs), l'[Approche / Sortie](lead-in-out) et la +[Passe Multiple](multi-pass) continuent de fonctionner comme d'habitude. ## Pages associées -- [Découpe de contour](operations/contour) - L'opération de découpe qui utilise la perforation -- [Ponts de maintien](holding-tabs) - Interruptions placées à la main pour garder les pièces +- [Coupe de Contour](operations/contour) — l'opération de coupe à laquelle appartient ce + post-traitement +- [Ponts de Maintien](holding-tabs) — interruptions placées à la main pour garder les pièces attachées diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/perforation.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/perforation.md index 315e37c62..7c5d4ec5a 100644 --- a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/perforation.md +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/perforation.md @@ -6,36 +6,58 @@ description: # परफोरेशन -**परफोरेशन** पोस्ट-प्रोसेसर लगातार कट को डैश वाले कट में बदल देता है। लेज़र **कट लंबाई** तक चलता है, -फिर **स्किप लंबाई** तक लेज़र बंद करके आगे बढ़ता है, और वर्कपीस के हर कंटूर पर यही दोहराता है। +हर कट का मकसद मटेरियल से पार्ट अलग करना नहीं होता। कुछ लाइनें इसलिए होती हैं कि मटेरियल उन पर मुड़े, +साफ फटे, या सुई का रास्ता पहले से निशान हो। इसी काम के लिए **परफोरेशन** पोस्ट-प्रोसेसर है: यह लगातार +कट को डैश वाले कट में बदल देता है। लेज़र को शुरू से आखिर तक जलाए रखने के बजाय यह **कट लंबाई** तक +चलता है, फिर **स्किप लंबाई** तक लेज़र बंद करके आगे बढ़ता है, और वर्कपीस के हर कंटूर पर यही लय +दोहराता है। नतीजा छोटे-छोटे कटों की एक लाइन होती है जो मटेरियल को जोड़े रखती है — जब तक आप खुद उसे +मोड़ना या फाड़ना न चाहें। -## कब उपयोग करें +![कंटूर स्टेप की पोस्ट-प्रोसेसिंग पेज पर परफोरेशन की सेटिंग्स](/screenshots/step-settings-contour-post-perforation.webp) -- कार्ड स्टॉक और नालीदार गत्ते में मोड़ की लाइनें -- फाड़ने की लाइनें (टिकट, कूपन, पैकेजिंग) -- चमड़े में सिलाई के छेद -- सजावटी डैश वाली लाइनें +## कुछ सामान्य उपयोग -## सेटिंग्स +जहाँ भी कोई लाइन मटेरियल को काटे बिना कमज़ोर करना चाहती है, वहाँ परफोरेटेड कट काम आता है। कुछ प्रमुख +उदाहरण: -परफोरेशन **कंटूर** ऑपरेशन की पोस्ट-प्रोसेसिंग सेटिंग्स में उपलब्ध है। यह डिफ़ॉल्ट रूप से बंद रहता -है। +- कार्ड स्टॉक और नालीदार गत्ते में **मोड़ की लाइनें** डैश वाले कट पर बिल्कुल साफ मुड़ती हैं — जैसे + डिब्बे, कार्ड और पॉप-अप डिज़ाइन। +- **फाड़ने की लाइनें** टिकट, कूपन और पैकेजिंग बिना कैंची खोल देती हैं। +- चमड़े में **सिलाई के छेद** सिलाई से पहले सुई का रास्ता दिखा देते हैं। +- **डैश वाली कंटूरें** अपने आप में भी एक सजावटी अंदाज़ हैं — लकड़ी, कागज़ और ऐक्रेलिक पर। -- **कट लंबाई**: हर गैप से पहले लेज़र जितनी दूरी तक चलता है। -- **स्किप लंबाई**: दो कट के बीच लेज़र बंद करके तय की गई दूरी। +## परफोरेशन चालू करना -पैटर्न वर्कपीस के हर कंटूर के साथ मापा जाता है और हर कंटूर की शुरुआत में फिर से शुरू होता है, हमेशा -एक पूरे कट से। एक कट और एक स्किप से छोटा कंटूर पूरा काटा जाता है। +परफोरेशन [कंटूर](operations/contour) स्टेप की सेटिंग्स में **पोस्ट-प्रोसेसिंग** पेज पर मिलती है, +बाकी पोस्ट-प्रोसेसरों के साथ। यह डिफ़ॉल्ट रूप से बंद रहती है; स्विच चालू करते ही दो मान सामने आते +हैं: + +- **कट लंबाई**: हर गैप से पहले लेज़र जितनी दूरी तक जलता रहता है। +- **स्किप लंबाई**: दो कटों के बीच लेज़र बंद करके तय की गई दूरी। + +पैटर्न हर कंटूर की शुरुआत में एक पूरे कट के साथ शुरू होता है और हर कंटूर पर नए सिरे से शुरू होता है, +ताकि वर्कपीस की सारी डैश वाली लाइनें एक जैसे अंदाज़ में शुरू हों। जो कंटूर एक कट और एक स्किप के लिए +भी छोटा हो, उसे पूरा काट दिया जाता है। + +## नतीजा + +कैनवस पर डैश वाली लाइन सीधे कट की सॉलिड रूपरेखा की जगह ले लेती है — टूलपाथ का हर गैप तैयार पार्ट में +भी गैप ही होता है। + +![एक आयत और एक वृत्त को प्लाईवुड की चादर में डैश वाली लाइनों की तरह काटा गया](/screenshots/operations-perforation.webp) ## सुझाव -- ऐसी मोड़ की लाइन के लिए जो आर-पार न कटे, कट लंबाई जितनी स्किप लंबाई से शुरू करें और पावर कम करें। -- बहुत छोटी लंबाइयाँ (मिलीमीटर के कुछ दसवें हिस्से) लेज़र को तेज़ी से चालू-बंद करती हैं, जिससे - प्रभावी पावर घटती है। -- परफोरेशन [होल्डिंग टैब](holding-tabs), [लीड-इन/आउट](lead-in-out) और [मल्टी-पास](multi-pass) के साथ - काम करता है। +जिस मोड़ की लाइन को आर-पार नहीं काटना है, उसके लिए कट लंबाई जितनी स्किप लंबाई से शुरू करें और पावर +कम करें — उथली नाली गहरे डैश वाले कट जितनी ही अच्छी मुड़ती है, और पिछला हिस्सा साफ रहता है। + +बहुत छोटी लंबाइयाँ (मिलीमीटर के कुछ दसवें हिस्से) लेज़र को तेज़ी से चालू-बंद करवाती हैं, जिससे +प्रभावी पावर घटती है और असमान निशान पड़ सकते हैं। पैटर्न असमान लगे तो लंबी वैल्यू आज़माएँ। + +परफोरेशन बाकी पोस्ट-प्रोसेसिंग के साथ अच्छी तरह चलती है: [होल्डिंग टैब](holding-tabs), +[लीड-इन / लीड-आउट](lead-in-out) और [मल्टी-पास](multi-pass) पहले की तरह ही काम करते रहते हैं। ## संबंधित पेज -- [कंटूर कटिंग](operations/contour) - वह कटिंग ऑपरेशन जो परफोरेशन का उपयोग करता है -- [होल्डिंग टैब](holding-tabs) - पार्टों को जोड़े रखने के लिए हाथ से लगाए गए गैप +- [कॉन्टूर कटिंग](operations/contour) — वह कटिंग ऑपरेशन जिससे यह पोस्ट-प्रोसेसर जुड़ा है +- [होल्डिंग टैब](holding-tabs) — पार्टों को जोड़े रखने के लिए हाथ से लगाए गए गैप diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/perforation.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/perforation.md index 9a3d40046..3235e9eff 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/perforation.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/perforation.md @@ -6,38 +6,62 @@ description: # Perfuração -O pós-processador **Perfuração** transforma um corte contínuo em um corte tracejado. O laser dispara -pelo **Comprimento de corte**, depois se desloca com o laser desligado pelo **Comprimento de -salto**, e repete isso ao longo de cada contorno da peça. +Nem todo corte serve para soltar uma peça do material. Algumas linhas existem para que o material +dobre sobre elas, se rasgue com limpeza ou mostre por onde a agulha vai passar. É para isso que +serve o pós-processador **Perfuração**: ele transforma um corte contínuo em um corte tracejado. Em +vez de manter o laser aceso do começo ao fim, ele dispara pelo **Comprimento de corte**, percorre o +**Comprimento de salto** com o laser desligado e repete esse ritmo ao longo de cada contorno da +peça. O que sobra é uma linha de cortes pequenos que mantém o material unido — até que você decida +dobrá-lo ou rasgá-lo. -## Quando usar +![As configurações da Perfuração na página de pós-processamento de uma etapa de Contorno](/screenshots/step-settings-contour-post-perforation.webp) -- Linhas de dobra em cartolina e papelão ondulado -- Linhas destacáveis (ingressos, cupons, embalagens) -- Furos de costura em couro -- Linhas tracejadas decorativas +## Alguns usos típicos -## Configurações +Sempre que uma linha deve enfraquecer o material sem cortá-lo por completo, vale a pena um corte +perfurado. Os casos clássicos: -A perfuração está disponível nas configurações de pós-processamento das operações de **Contorno**. -Ela vem desativada por padrão. +- **Linhas de dobra** em cartolina e papelão ondulado dobram com precisão sobre um corte tracejado — + caixas, cartões e designs pop-up, por exemplo. +- **Linhas destacáveis** abrem ingressos, cupons e embalagens sem tesoura. +- **Furos de costura** em couro marcam o caminho da agulha antes de costurar. +- **Contornos tracejados** são, por si só, um efeito decorativo — em madeira, papel e acrílico. -- **Comprimento de corte**: a distância em que o laser dispara antes de cada intervalo. -- **Comprimento de salto**: a distância percorrida com o laser desligado entre dois cortes. +## Ativar a perfuração -O padrão é medido ao longo de cada contorno da peça e recomeça no início de cada contorno, sempre -com um corte completo. Um contorno menor que um corte mais um salto é cortado inteiro. +A perfuração fica na página **Pós-processamento** das configurações de uma etapa de +[Contorno](operations/contour), junto aos demais pós-processadores. Ela vem desativada por padrão; +ao ligar o interruptor, aparecem dois valores: + +- O **Comprimento de corte** é a distância em que o laser permanece aceso antes de cada intervalo. +- O **Comprimento de salto** é a distância percorrida com o laser desligado entre dois cortes. + +O padrão recomeça no início de cada contorno, sempre com um corte completo, para que todas as linhas +tracejadas da peça comecem da mesma forma. Um contorno curto demais para um único corte e um salto é +simplesmente cortado inteiro. + +## O resultado + +Na área de desenho, a linha tracejada substitui o contorno de corte sólido: cada intervalo do +caminho será também um intervalo na peça pronta. + +![Um retângulo e um círculo cortados como linhas tracejadas em uma chapa de compensado](/screenshots/operations-perforation.webp) ## Dicas -- Para uma linha de dobra que não deve atravessar o material, comece com um comprimento de salto - parecido com o de corte e reduza a potência. -- Comprimentos muito curtos (alguns décimos de milímetro) fazem o laser ligar e desligar - rapidamente, o que reduz a potência efetiva. -- A perfuração funciona junto com [Abas de fixação](holding-tabs), [Entrada/Saída](lead-in-out) e - [Múltiplas passadas](multi-pass). +Para uma linha de dobra que não deve atravessar o material, comece com um comprimento de salto +parecido com o de corte e reduza a potência: um sulco raso dobra tão bem quanto um corte tracejado +profundo, e o verso fica limpo. + +Comprimentos muito curtos (alguns décimos de milímetro) fazem o laser ligar e desligar em sequência +rápida, o que reduz a potência efetiva e pode deixar marcas irregulares. Se o padrão aparecer +desigual, experimente valores maiores. + +A perfuração convive bem com o resto do pós-processamento: as [Abas de Fixação](holding-tabs), o +[Aproximação / Saída](lead-in-out) e a [Multi-Passagem](multi-pass) continuam funcionando como +sempre. ## Páginas relacionadas -- [Corte de contorno](operations/contour) - A operação de corte que usa a perfuração -- [Abas de fixação](holding-tabs) - Intervalos colocados à mão para manter as peças presas +- [Corte de Contorno](operations/contour) — a operação de corte à qual este pós-processador pertence +- [Abas de Fixação](holding-tabs) — intervalos colocados à mão para manter as peças presas diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/perforation.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/perforation.md index b6d24300e..e04c31a1a 100644 --- a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/perforation.md +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/perforation.md @@ -6,38 +6,60 @@ description: # Перфорація -Постобробка **Перфорація** перетворює суцільний різ на пунктирний. Лазер ріже на **Довжині різу**, -потім рухається з вимкненим лазером на **Довжині пропуску** і повторює це вздовж кожного контуру -заготовки. +Не кожен різ має відділити деталь від матеріалу. Деякі лінії потрібні, щоб матеріал згинався вздовж +них, чисто відривався чи показував, куди піде голка. Для цього існує постобробка **Перфорація**: +вона перетворює суцільний різ на пунктирний. Замість того щоб тримати лазер увімкненим від початку +до кінця, він різе на **Довжині різу**, проходить **Довжину пропуску** з вимкненим лазером і +повторює цей ритм уздовж кожного контуру заготовки. У результаті лишається лінія з дрібних різів, +яка тримає матеріал докупи — доти, доки ви не захочете його зігнути чи відрвати. -## Коли використовувати +![Налаштування Перфорації на сторінці постобробки кроку Контур](/screenshots/step-settings-contour-post-perforation.webp) -- Лінії згину в картоні та гофрокартоні -- Відривні лінії (квитки, купони, упаковка) -- Отвори для шиття в шкірі -- Декоративні пунктирні лінії +## Типові застосування -## Налаштування +Скрізь, де лінія має послабити матеріал, не розрізаючи його, варто спробувати перфорований різ. +Класичні приклади: -Перфорація доступна в налаштуваннях постобробки операцій **Контур**. За замовчуванням вона вимкнена. +- **Лінії згину** в картоні та гофрокартоні згинаються рівно вздовж пунктирного різу — коробки, + листівки та pop-up дизайни. +- **Відривні лінії** дають змогу відкривати квитки, купони та упаковку без ножиць. +- **Отвори для шиття** в шкірі позначають шлях голки ще до шиття. +- **Пунктирні контури** самі собою є декоративним ефектом — на дереві, папері та акрилі. -- **Довжина різу**: відстань, на якій лазер ріже перед кожним проміжком. -- **Довжина пропуску**: відстань, яку лазер проходить вимкненим між двома різами. +## Увімкнення перфорації -Візерунок вимірюється вздовж кожного контуру заготовки й починається заново на початку кожного -контуру, завжди з повного різу. Контур, коротший за один різ плюс один пропуск, вирізається -повністю. +Перфорація живе на сторінці **Постобробка** в налаштуваннях кроку [Контур](operations/contour), +поруч з іншими постобробками. Типово вона вимкнена; щойно ви перемикаєте тумблер, з'являються два +значення: + +- **Довжина різу** — як далеко лазер лишається увімкненим перед кожним проміжком. +- **Довжина пропуску** — відстань, яку лазер проходить вимкненим між двома різами. + +Візерунок починається на початку кожного контуру з повного різу й поновлюється на кожному контурі, +тож усі пунктирні лінії заготовки починаються однаково. Контур, замалий навіть для одного різу й +одного пропуску, просто вирізається цілим. + +## Результат + +На полотні пунктирна лінія замінює суцільний контур різу — кожен проміжок у траєкторії буде +проміжком і в готовій деталі. + +![Прямокутник і коло, вирізані пунктирними лініями в аркуші фанери](/screenshots/operations-perforation.webp) ## Поради -- Для лінії згину, яка не повинна прорізати наскрізь, почніть із довжиною пропуску, приблизно рівною - довжині різу, і зменште потужність. -- Дуже малі довжини (кілька десятих міліметра) змушують лазер швидко вмикатися й вимикатися, що - зменшує ефективну потужність. -- Перфорація працює разом з [Утримуючими лапками](holding-tabs), [Входом/виходом](lead-in-out) і - [Багатопрохідним різанням](multi-pass). +Для лінії згину, яка не повинна прорізати наскрізь, почніть із довжиною пропуску, приблизно рівною +довжині різу, і зменште потужність — мілка канавка згинається так само добре, як глибокий пунктирний +різ, а виворіт лишається чистим. + +Дуже малі довжини (кілька десятих міліметра) змушують лазер швидко вмикатися й вимикатися, що +зменшує ефективну потужність і може лишати нерівні сліди. Якщо візерунок виглядає нерівномірно, +спробуйте більші значення. + +Перфорація добре поєднується з рештою постобробки: [Утримуючі лапки](holding-tabs), +[Вхід / Вихід](lead-in-out) і [Багатопрохідність](multi-pass) працюють як завжди. ## Пов'язані сторінки -- [Контурне різання](operations/contour) - Операція різання, що використовує перфорацію -- [Утримуючі лапки](holding-tabs) - Проміжки, розставлені вручну, щоб деталі не випадали +- [Контурне різання](operations/contour) — операція різання, до якої належить ця постобробка +- [Утримуючі лапки](holding-tabs) — проміжки, розставлені вручну, щоб деталі не випадали diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/perforation.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/perforation.md index a67f3ad98..58e32fb8a 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/perforation.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/perforation.md @@ -4,31 +4,44 @@ description: "使用穿孔后处理器在 Rayforge 中切割虚线:为折线 # 穿孔 -**穿孔**后处理会把连续切割变成虚线切割。激光在**切割长度**内出光,然后在**跳过长度**内关闭激光移动,并沿工件的每条轮廓重复。 +并不是每条切割线都是为了把零件从材料上取下来。有些线的意义在于让材料沿着它折叠、干净地撕开,或预先标出缝针的走向。**穿孔**后处理正是为此而生:它把连续切割变成虚线切割。激光不再从头到尾一直出光,而是按**切割长度**出光,再关闭激光走过**跳过长度**,并沿工件的每条轮廓重复这一节奏。留下的是一串小切割,它们把材料连在一起——直到你想让它折或撕开的那一刻。 -## 何时使用 +![轮廓步骤后处理页面中的穿孔设置](/screenshots/step-settings-contour-post-perforation.webp) -- 卡纸和瓦楞纸板的折线 -- 撕线(门票、优惠券、包装) -- 皮革上的缝线孔 -- 装饰性虚线 +## 典型用途 -## 设置 +凡是需要削弱材料而又不把它切开的场合,都值得考虑穿孔切割。常见的例子: -穿孔位于**轮廓**操作的后处理设置中,默认关闭。 +- 卡纸和瓦楞纸板上的**折线**沿虚线切口干净利落地折叠——比如盒子、卡片和立体弹出设计。 +- **撕线**让门票、优惠券和包装不需要剪刀就能打开。 +- 皮革上的**缝线孔**在缝纫前标出针的走向。 +- **虚线轮廓**本身就是一种装饰效果,适合木材、纸张和亚克力。 -- **切割长度**:每个间隙前激光出光的距离。 +## 启用穿孔 + +穿孔位于[轮廓](operations/contour)步骤设置中的**后处理**页面,与其他后处理并列。它默认关闭;打开开关后会出现两个数值: + +- **切割长度**:每个间隙前激光持续出光的距离。 - **跳过长度**:两段切割之间关闭激光移动的距离。 -图案沿工件的每条轮廓测量,并在每条轮廓的起点重新开始,总是以一段完整的切割开始。短于一段切割加一段跳过的轮廓会被完整切割。 +图案从每条轮廓的起点的一段完整切割开始,并在每条轮廓上重新开始,因此工件上所有虚线都以同样的方式起头。短到连一段切割加一段跳跃都放不下的轮廓会被完整切下。 + +## 效果 + +在画布上,虚线取代了实线切割轮廓——刀具路径上的每个间隙就是成品零件上的每个间隙。 + +![矩形和圆形以虚线形式切入桦木胶合板](/screenshots/operations-perforation.webp) ## 提示 -- 对于不应切穿的折线,可以先让跳过长度与切割长度大致相同,并降低功率。 -- 非常短的长度(零点几毫米)会让激光快速开关,从而降低有效功率。 -- 穿孔可与[固定标签](holding-tabs)、[引入/引出](lead-in-out)和[多次切割](multi-pass)一起使用。 +对于不应切穿的折线,可以先让跳过长度与切割长度大致相同,并降低功率——浅浅的压痕和深深的虚线切口一样容易折,而且背面保持整洁。 + +非常短的长度(零点几毫米)会让激光快速开关,降低有效功率,还可能留下不均匀的痕迹。如果图案看起来不一致,试试更长的数值。 + +穿孔与后处理的其他功能配合良好:[固定标签](holding-tabs)、[引入 / 引出](lead-in-out)和 +[多次遍](multi-pass)都照常工作。 ## 相关页面 -- [轮廓切割](operations/contour) - 使用穿孔的切割操作 -- [固定标签](holding-tabs) - 手动放置以固定零件的间隙 +- [轮廓切割](operations/contour) — 本后处理所属的切割操作 +- [固定标签](holding-tabs) — 手动放置以固定零件的间隙 From e4654cc42c67524979a7614cb740f7d46a6b09b1 Mon Sep 17 00:00:00 2001 From: Samuel Abels Date: Thu, 15 Oct 2026 13:40:40 +0200 Subject: [PATCH 091/104] feat(doceditor): let the step settings dialog open on the Workpieces page --- rayforge/ui_gtk/doceditor/step_settings/dialog.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/rayforge/ui_gtk/doceditor/step_settings/dialog.py b/rayforge/ui_gtk/doceditor/step_settings/dialog.py index a7769dc77..3795995da 100644 --- a/rayforge/ui_gtk/doceditor/step_settings/dialog.py +++ b/rayforge/ui_gtk/doceditor/step_settings/dialog.py @@ -221,6 +221,9 @@ def set_initial_page(self, page: str): if page == "post-processing": self.btn_post_processing.set_active(True) return + if page == "workpieces" and self.btn_workpieces is not None: + self.btn_workpieces.set_active(True) + return for index, (page_title, extra_page, icon_name) in enumerate( self._extra_pages ): From 90dfd2aef3869c1abc088b652a3a0add52140819 Mon Sep 17 00:00:00 2001 From: Samuel Abels Date: Thu, 15 Oct 2026 13:40:40 +0200 Subject: [PATCH 092/104] docs(operations): document the Workpieces tab Adds a screenshot target that opens the Material Test Grid's step settings on the Workpieces tab, and extends the operation pages: the Material Test Grid page explains the link to its generated workpiece, and the pages listing the dialog tabs count the new one. All translated pages are updated accordingly. --- scripts/screenshot/cli.py | 3 + .../screenshot/step_settings_workpieces.py | 57 ++++++++++++++++++ website/docs/features/operations/contour.md | 4 +- .../docs/features/operations/frame-outline.md | 4 +- .../features/operations/material-test-grid.md | 10 +++ .../docs/features/operations/shrink-wrap.md | 4 +- website/docs/features/operations/wavefront.md | 4 +- .../current/features/operations/contour.md | 5 +- .../features/operations/frame-outline.md | 5 +- .../features/operations/material-test-grid.md | 10 +++ .../features/operations/shrink-wrap.md | 5 +- .../current/features/operations/wavefront.md | 5 +- .../current/features/operations/contour.md | 5 +- .../features/operations/frame-outline.md | 5 +- .../features/operations/material-test-grid.md | 10 +++ .../features/operations/shrink-wrap.md | 5 +- .../current/features/operations/wavefront.md | 5 +- .../current/features/operations/contour.md | 5 +- .../features/operations/frame-outline.md | 5 +- .../features/operations/material-test-grid.md | 10 +++ .../features/operations/shrink-wrap.md | 5 +- .../current/features/operations/wavefront.md | 5 +- .../current/features/operations/contour.md | 4 +- .../features/operations/frame-outline.md | 4 +- .../features/operations/material-test-grid.md | 9 +++ .../features/operations/shrink-wrap.md | 4 +- .../current/features/operations/wavefront.md | 4 +- .../current/features/operations/contour.md | 5 +- .../features/operations/frame-outline.md | 5 +- .../features/operations/material-test-grid.md | 10 +++ .../features/operations/shrink-wrap.md | 5 +- .../current/features/operations/wavefront.md | 5 +- .../current/features/operations/contour.md | 4 +- .../features/operations/frame-outline.md | 4 +- .../features/operations/material-test-grid.md | 9 +++ .../features/operations/shrink-wrap.md | 4 +- .../current/features/operations/wavefront.md | 4 +- .../current/features/operations/contour.md | 2 +- .../features/operations/frame-outline.md | 2 +- .../features/operations/material-test-grid.md | 6 ++ .../features/operations/shrink-wrap.md | 2 +- .../current/features/operations/wavefront.md | 2 +- ...ettings-material-test-grid-workpieces.webp | Bin 0 -> 11716 bytes 43 files changed, 210 insertions(+), 60 deletions(-) create mode 100644 scripts/screenshot/step_settings_workpieces.py create mode 100644 website/static/screenshots/step-settings-material-test-grid-workpieces.webp diff --git a/scripts/screenshot/cli.py b/scripts/screenshot/cli.py index 5a494784b..a563aafd8 100755 --- a/scripts/screenshot/cli.py +++ b/scripts/screenshot/cli.py @@ -133,6 +133,9 @@ "recipe-editor:post-processing": "recipe_editor_settings", "sanity-check": "sanity_check", "step-settings:command:general": "step_settings_command", + "step-settings:material-test-grid:workpieces": ( + "step_settings_workpieces" + ), "step-settings:contour:general": "step_settings", "step-settings:contour:laser": "step_settings", "step-settings:contour:post": "step_settings", diff --git a/scripts/screenshot/step_settings_workpieces.py b/scripts/screenshot/step_settings_workpieces.py new file mode 100644 index 000000000..322e4868b --- /dev/null +++ b/scripts/screenshot/step_settings_workpieces.py @@ -0,0 +1,57 @@ +"""Screenshot: Workpieces tab of the step settings dialog.""" + +import logging +import time + +from utils import ( + find_step_by_type, + get_target, + load_project, + open_step_settings, + restore_config, + run_on_main_thread, + set_window_size, + take_screenshot, +) + +from rayforge.uiscript import app, win + +logger = logging.getLogger(__name__) + +TARGET = get_target("step-settings:material-test-grid:workpieces") + + +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() + + +@restore_config +def main(): + set_window_size(win, 2400, 1650) + + load_project(win, "allsteps.ryp") + time.sleep(0.25) + + run_on_main_thread(_create_material_test) + time.sleep(0.25) + + found = find_step_by_type(win, "material-test-grid") + if found is None or found[0] is None: + logger.error("Material test grid step not found") + app.quit_idle() + return + + step, step_index = found + logger.info("Found step: %s at index %s", step.name, step_index) + + open_step_settings(win, step_index=step_index, page="workpieces") + time.sleep(0.5) + + take_screenshot() + time.sleep(0.25) + app.quit_idle() + + +main() diff --git a/website/docs/features/operations/contour.md b/website/docs/features/operations/contour.md index b6615b6fd..4fd68be3d 100644 --- a/website/docs/features/operations/contour.md +++ b/website/docs/features/operations/contour.md @@ -54,8 +54,8 @@ Use contour cutting for: ## Key Settings -The step settings dialog has three tabs: **Step Settings**, **Laser**, and **Post Processing**. -Settings are described in tab order below. +The step settings dialog has four tabs: **Step Settings**, **Laser**, **Post Processing**, and +**Workpieces**. Settings are described in tab order below. ### Contour Settings diff --git a/website/docs/features/operations/frame-outline.md b/website/docs/features/operations/frame-outline.md index 78e45e0ab..a9d992c48 100644 --- a/website/docs/features/operations/frame-outline.md +++ b/website/docs/features/operations/frame-outline.md @@ -56,8 +56,8 @@ Configure the frame parameters: ## Key Settings -The step settings dialog has three tabs: **Step Settings**, **Laser**, and **Post Processing**. -Settings are described in tab order below. +The step settings dialog has four tabs: **Step Settings**, **Laser**, **Post Processing**, and +**Workpieces**. Settings are described in tab order below. ### Geometry diff --git a/website/docs/features/operations/material-test-grid.md b/website/docs/features/operations/material-test-grid.md index 413eb7347..869bc8c25 100644 --- a/website/docs/features/operations/material-test-grid.md +++ b/website/docs/features/operations/material-test-grid.md @@ -67,6 +67,16 @@ The **Parameters** section holds the ranges the grid varies — speed, power, pa 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. +## The Linked Workpiece + +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. + +![Workpieces tab of the Material Test Grid settings](/screenshots/step-settings-material-test-grid-workpieces.webp) + ## Understanding the Layout In the default Power vs Speed mode, power increases from left to right and speed from top to bottom: diff --git a/website/docs/features/operations/shrink-wrap.md b/website/docs/features/operations/shrink-wrap.md index 0465b7533..0dfae3546 100644 --- a/website/docs/features/operations/shrink-wrap.md +++ b/website/docs/features/operations/shrink-wrap.md @@ -70,8 +70,8 @@ maintaining clearance. ## Key Settings -The step settings dialog has three tabs: **Step Settings**, **Laser**, and **Post Processing**. -Settings are described in tab order below. +The step settings dialog has four tabs: **Step Settings**, **Laser**, **Post Processing**, and +**Workpieces**. Settings are described in tab order below. ### Shrink Wrap diff --git a/website/docs/features/operations/wavefront.md b/website/docs/features/operations/wavefront.md index 1907d9b65..97f94701a 100644 --- a/website/docs/features/operations/wavefront.md +++ b/website/docs/features/operations/wavefront.md @@ -66,8 +66,8 @@ Adjust step-over and offset to match your material and desired finish. ## Key Settings -The step settings dialog has three tabs: **Step Settings**, **Laser**, and **Post Processing**. -Settings are described in tab order below. +The step settings dialog has four tabs: **Step Settings**, **Laser**, **Post Processing**, and +**Workpieces**. Settings are described in tab order below. ### Wavefront Settings diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/contour.md b/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/contour.md index 59338b9fd..a55d26759 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/contour.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/contour.md @@ -48,8 +48,9 @@ Verwende Kontur-Schneiden für: ## Haupt-Einstellungen -Der Schritt-Einstellungen-Dialog hat drei Registerkarten: **Schritt-Einstellungen**, **Laser** und -**Nachbearbeitung**. Die Einstellungen werden unten in Registerkarten-Reihenfolge beschrieben. +Der Schritt-Einstellungen-Dialog hat vier Registerkarten: **Schritt-Einstellungen**, **Laser**, +**Nachbearbeitung** und **Werkstücke**. Die Einstellungen werden unten in Registerkarten-Reihenfolge +beschrieben. ### Kontur-Einstellungen diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/frame-outline.md b/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/frame-outline.md index e7458b337..d24af4b0f 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/frame-outline.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/frame-outline.md @@ -51,8 +51,9 @@ Konfiguriere die Rahmen-Parameter: ## Haupt-Einstellungen -Der Schritt-Einstellungen-Dialog hat drei Registerkarten: **Schritt-Einstellungen**, **Laser** und -**Nachbearbeitung**. Die Einstellungen werden unten in Registerkarten-Reihenfolge beschrieben. +Der Schritt-Einstellungen-Dialog hat vier Registerkarten: **Schritt-Einstellungen**, **Laser**, +**Nachbearbeitung** und **Werkstücke**. Die Einstellungen werden unten in Registerkarten-Reihenfolge +beschrieben. ### Geometrie 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 a3e6a3881..658658e8e 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 @@ -73,6 +73,16 @@ Der Bereich **Parameter** enthält die Bereiche, die das Raster variiert — Ges 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. +## Das verknüpfte Werkstück + +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. + +![Werkstücke-Reiter der Materialtest-Raster-Einstellungen](/screenshots/step-settings-material-test-grid-workpieces.webp) + ## Das Raster-Layout verstehen Im Standardmodus Leistung vs. Geschwindigkeit nimmt die Leistung von links nach rechts zu und die diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/shrink-wrap.md b/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/shrink-wrap.md index 0dd36df8e..94674a337 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/shrink-wrap.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/shrink-wrap.md @@ -65,8 +65,9 @@ Spielraum erhalten bleibt. ## Haupt-Einstellungen -Der Schritt-Einstellungen-Dialog hat drei Registerkarten: **Schritt-Einstellungen**, **Laser** und -**Nachbearbeitung**. Die Einstellungen werden unten in Registerkarten-Reihenfolge beschrieben. +Der Schritt-Einstellungen-Dialog hat vier Registerkarten: **Schritt-Einstellungen**, **Laser**, +**Nachbearbeitung** und **Werkstücke**. Die Einstellungen werden unten in Registerkarten-Reihenfolge +beschrieben. ### Schrumpfumhüllung diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/wavefront.md b/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/wavefront.md index f2a983399..67c9d5079 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/wavefront.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/wavefront.md @@ -59,8 +59,9 @@ Schrittweite und Offset an dein Material und das gewünschte Finish anpassen. ## Haupt-Einstellungen -Der Schritt-Einstellungen-Dialog hat drei Registerkarten: **Schritt-Einstellungen**, **Laser** und -**Nachbearbeitung**. Die Einstellungen werden unten in Registerkarten-Reihenfolge beschrieben. +Der Schritt-Einstellungen-Dialog hat vier Registerkarten: **Schritt-Einstellungen**, **Laser**, +**Nachbearbeitung** und **Werkstücke**. Die Einstellungen werden unten in Registerkarten-Reihenfolge +beschrieben. ### Wellenfront-Einstellungen diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/contour.md b/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/contour.md index b7a160a16..90a596d7f 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/contour.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/contour.md @@ -48,8 +48,9 @@ Usa el corte de contorno para: ## Ajustes Principales -El diálogo de ajustes de paso tiene tres pestañas: **Ajustes de Paso**, **Láser** y -**Post-Procesamiento**. Los ajustes se describen en orden de pestañas a continuación. +El diálogo de ajustes de paso tiene cuatro pestañas: **Ajustes de Paso**, **Láser**, +**Post-Procesamiento** y **Piezas de trabajo**. Los ajustes se describen en orden de pestañas a +continuación. ### Ajustes de Contorno diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/frame-outline.md b/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/frame-outline.md index 3a749e5f7..a6babf3cf 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/frame-outline.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/frame-outline.md @@ -51,8 +51,9 @@ Configura los parámetros del marco: ## Ajustes Principales -El diálogo de ajustes de paso tiene tres pestañas: **Ajustes de Paso**, **Láser** y -**Post-Procesamiento**. Los ajustes se describen en orden de pestañas a continuación. +El diálogo de ajustes de paso tiene cuatro pestañas: **Ajustes de Paso**, **Láser**, +**Post-Procesamiento** y **Piezas de trabajo**. Los ajustes se describen en orden de pestañas a +continuación. ### Geometría 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 8b687bca2..ca45ee637 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 @@ -70,6 +70,16 @@ La sección **Parámetros** contiene los rangos que varía la cuadrícula — ve 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 + +Normalmente una operación procesa todas las piezas de trabajo de su capa, y la pestaña **Piezas de +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. + +![Pestaña Piezas de trabajo de la Cuadrícula de Prueba de Material](/screenshots/step-settings-material-test-grid-workpieces.webp) + ## Entendiendo el Diseño En el modo predeterminado Potencia vs Velocidad, la potencia aumenta de izquierda a derecha y la diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/shrink-wrap.md b/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/shrink-wrap.md index 7e4ab9c75..e96be45cc 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/shrink-wrap.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/shrink-wrap.md @@ -67,8 +67,9 @@ mantiene la holgura. ## Ajustes Clave -El diálogo de ajustes de paso tiene tres pestañas: **Ajustes de Paso**, **Láser** y -**Post-Procesamiento**. Los ajustes se describen en orden de pestañas a continuación. +El diálogo de ajustes de paso tiene cuatro pestañas: **Ajustes de Paso**, **Láser**, +**Post-Procesamiento** y **Piezas de trabajo**. Los ajustes se describen en orden de pestañas a +continuación. ### Envoltura Contraída diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/wavefront.md b/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/wavefront.md index 12278737e..21df9c6ff 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/wavefront.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/wavefront.md @@ -59,8 +59,9 @@ Ajusta el paso y el desplazamiento según tu material y el acabado deseado. ## Ajustes Clave -El diálogo de ajustes de paso tiene tres pestañas: **Ajustes de Paso**, **Láser** y -**Post-Procesamiento**. Los ajustes se describen en orden de pestañas a continuación. +El diálogo de ajustes de paso tiene cuatro pestañas: **Ajustes de Paso**, **Láser**, +**Post-Procesamiento** y **Piezas de trabajo**. Los ajustes se describen en orden de pestañas a +continuación. ### Ajustes de Frente de Onda diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/contour.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/contour.md index 768206b5a..c13df3dfe 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/contour.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/contour.md @@ -49,8 +49,9 @@ Utilisez la coupe de contour pour : ## Paramètres Clés -La boîte de dialogue des paramètres d'étape comporte trois onglets : **Paramètres d'étape**, -**Laser** et **Post-Traitement**. Les paramètres sont décrits ci-dessous dans l'ordre des onglets. +La boîte de dialogue des paramètres d'étape comporte quatre onglets : **Paramètres d'étape**, +**Laser**, **Post-Traitement** et **Pièces**. Les paramètres sont décrits ci-dessous dans l'ordre +des onglets. ### Paramètres de Contour diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/frame-outline.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/frame-outline.md index 99e63c970..4f3b9e539 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/frame-outline.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/frame-outline.md @@ -51,8 +51,9 @@ Configurez les paramètres du cadre : ## Paramètres Clés -La boîte de dialogue des paramètres d'étape comporte trois onglets : **Paramètres d'étape**, -**Laser** et **Post-Traitement**. Les paramètres sont décrits ci-dessous dans l'ordre des onglets. +La boîte de dialogue des paramètres d'étape comporte quatre onglets : **Paramètres d'étape**, +**Laser**, **Post-Traitement** et **Pièces**. Les paramètres sont décrits ci-dessous dans l'ordre +des onglets. ### Géométrie 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 71c190f28..e70c372e4 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 @@ -73,6 +73,16 @@ La section **Paramètres** contient les plages que la grille fait varier — vit 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. + +![Onglet Pièces des paramètres de la Grille de Test de Matériau](/screenshots/step-settings-material-test-grid-workpieces.webp) + ## Comprendre la Disposition Dans le mode Puissance vs Vitesse par défaut, la puissance augmente de gauche à droite et la vitesse diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/shrink-wrap.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/shrink-wrap.md index 542d16627..6a8414f08 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/shrink-wrap.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/shrink-wrap.md @@ -65,8 +65,9 @@ maintenant le dégagement. ## Paramètres Clés -La boîte de dialogue des paramètres d'étape comporte trois onglets : **Paramètres d'étape**, -**Laser** et **Post-Traitement**. Les paramètres sont décrits ci-dessous dans l'ordre des onglets. +La boîte de dialogue des paramètres d'étape comporte quatre onglets : **Paramètres d'étape**, +**Laser**, **Post-Traitement** et **Pièces**. Les paramètres sont décrits ci-dessous dans l'ordre +des onglets. ### Shrink Wrap diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/wavefront.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/wavefront.md index bce7e9f5e..c3d8c7148 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/wavefront.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/wavefront.md @@ -60,8 +60,9 @@ Ajustez le pas et le décalage en fonction de votre matériau et de la finition ## Paramètres Clés -La boîte de dialogue des paramètres d'étape comporte trois onglets : **Paramètres d'étape**, -**Laser** et **Post-Traitement**. Les paramètres sont décrits ci-dessous dans l'ordre des onglets. +La boîte de dialogue des paramètres d'étape comporte quatre onglets : **Paramètres d'étape**, +**Laser**, **Post-Traitement** et **Pièces**. Les paramètres sont décrits ci-dessous dans l'ordre +des onglets. ### Paramètres de Front d'Onde diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/contour.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/contour.md index 6b3031fdc..bdb5f8526 100644 --- a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/contour.md +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/contour.md @@ -54,8 +54,8 @@ description: ## मुख्य सेटिंग्स -स्टेप सेटिंग्स संवाद में तीन टैब हैं: **Step Settings**, **Laser**, और **Post Processing**. सेटिंग्स -नीचे टैब क्रम में वर्णित हैं. +स्टेप सेटिंग्स संवाद में चार टैब हैं: **Step Settings**, **Laser**, **Post Processing**, और +**वर्कपीस**. सेटिंग्स नीचे टैब क्रम में वर्णित हैं. ### कॉन्टूर सेटिंग्स diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/frame-outline.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/frame-outline.md index accd91519..85522fae0 100644 --- a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/frame-outline.md +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/frame-outline.md @@ -56,8 +56,8 @@ description: ## मुख्य सेटिंग्स -स्टेप सेटिंग्स संवाद में तीन टैब हैं: **Step Settings**, **Laser**, और **Post Processing**. सेटिंग्स -नीचे टैब क्रम में वर्णित हैं. +स्टेप सेटिंग्स संवाद में चार टैब हैं: **Step Settings**, **Laser**, **Post Processing**, और +**वर्कपीस**. सेटिंग्स नीचे टैब क्रम में वर्णित हैं. ### ज्यामिति 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 e1c086c57..235cde72f 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 @@ -90,6 +90,15 @@ Rayforge सामान्य प्रसंगों के लिए प् - छोटे मान घने भराव बनाते हैं लेकिन निष्पादन में अधिक समय लेते हैं - सामान्य मान: 0.1-0.3mm +## जुड़ा हुआ वर्कपीस + +आम तौर पर एक ऑपरेशन अपनी लेयर के सभी वर्कपीस प्रोसेस करता है, और उसके सेटिंग्स संवाद का **वर्कपीस** +टैब उन सभी को शामिल के रूप में सूचीबद्ध करता है. सामग्री परीक्षण ग्रिड इसका अपवाद है: इसका ऑपरेशन उस +वर्कपीस से जुड़ा है जो उसने बनाया और केवल उसी को प्रोसेस करता है. वर्कपीस टैब पर बनाया गया ग्रिड +एकमात्र शामिल वर्कपीस के रूप में दिखता है, जबकि लेयर का बाकी सब कुछ बाहर चिह्नित होता है. + +![सामग्री परीक्षण ग्रिड सेटिंग्स का वर्कपीस टैब](/screenshots/step-settings-material-test-grid-workpieces.webp) + ## ग्रिड लेआउट समझना ### ग्रिड संगठन diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/shrink-wrap.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/shrink-wrap.md index f31e71d08..bef73a00f 100644 --- a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/shrink-wrap.md +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/shrink-wrap.md @@ -69,8 +69,8 @@ description: ## मुख्य सेटिंग्स -स्टेप सेटिंग्स संवाद में तीन टैब हैं: **Step Settings**, **Laser**, और **Post Processing**. सेटिंग्स -नीचे टैब क्रम में वर्णित हैं. +स्टेप सेटिंग्स संवाद में चार टैब हैं: **Step Settings**, **Laser**, **Post Processing**, और +**वर्कपीस**. सेटिंग्स नीचे टैब क्रम में वर्णित हैं. ### श्रिंक रैप diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/wavefront.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/wavefront.md index 16cd76fce..c3f6dc3de 100644 --- a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/wavefront.md +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/wavefront.md @@ -64,8 +64,8 @@ description: ## मुख्य सेटिंग्स -स्टेप सेटिंग्स संवाद में तीन टैब हैं: **Step Settings**, **Laser**, और **Post Processing**. सेटिंग्स -नीचे टैब क्रम में वर्णित हैं. +स्टेप सेटिंग्स संवाद में चार टैब हैं: **Step Settings**, **Laser**, **Post Processing**, और +**वर्कपीस**. सेटिंग्स नीचे टैब क्रम में वर्णित हैं. ### वेवफ़्रंट सेटिंग्स diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/contour.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/contour.md index ce07b0f9a..cf4077c71 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/contour.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/contour.md @@ -48,8 +48,9 @@ Use corte de contorno para: ## Configurações Principais -O diálogo de configurações de etapa tem três abas: **Configurações de Etapa**, **Laser** e -**Pós-Processamento**. As configurações são descritas em ordem de aba abaixo. +O diálogo de configurações de etapa tem quatro abas: **Configurações de Etapa**, **Laser**, +**Pós-Processamento** e **Peças de trabalho**. As configurações são descritas em ordem de aba +abaixo. ### Configurações de Contorno diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/frame-outline.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/frame-outline.md index c776805ae..e1fd283c0 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/frame-outline.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/frame-outline.md @@ -50,8 +50,9 @@ Configure os parâmetros da moldura: ## Configurações Principais -O diálogo de configurações de etapa tem três abas: **Configurações de Etapa**, **Laser** e -**Pós-Processamento**. As configurações são descritas em ordem de aba abaixo. +O diálogo de configurações de etapa tem quatro abas: **Configurações de Etapa**, **Laser**, +**Pós-Processamento** e **Peças de trabalho**. As configurações são descritas em ordem de aba +abaixo. ### Geometria 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 2518184db..ad1a6859b 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 @@ -71,6 +71,16 @@ A seção **Parâmetros** contém as faixas que a grade varia — velocidade, po 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. +## A Peça de Trabalho Vinculada + +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. + +![Aba Peças de trabalho da Grade de Teste de Material](/screenshots/step-settings-material-test-grid-workpieces.webp) + ## Entendendo o Layout No modo padrão Potência vs Velocidade, a potência aumenta da esquerda para a direita e a velocidade diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/shrink-wrap.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/shrink-wrap.md index 5dca10fa2..cb7b49803 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/shrink-wrap.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/shrink-wrap.md @@ -64,8 +64,9 @@ folga. ## Configurações Principais -O diálogo de configurações de etapa tem três abas: **Configurações de Etapa**, **Laser** e -**Pós-Processamento**. As configurações são descritas em ordem de aba abaixo. +O diálogo de configurações de etapa tem quatro abas: **Configurações de Etapa**, **Laser**, +**Pós-Processamento** e **Peças de trabalho**. As configurações são descritas em ordem de aba +abaixo. ### Shrink Wrap diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/wavefront.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/wavefront.md index a47291410..86a70f6ec 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/wavefront.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/wavefront.md @@ -59,8 +59,9 @@ Ajuste o passo e o deslocamento para combinar com seu material e acabamento dese ## Configurações Principais -O diálogo de configurações de etapa tem três abas: **Configurações de Etapa**, **Laser** e -**Pós-Processamento**. As configurações são descritas em ordem de aba abaixo. +O diálogo de configurações de etapa tem quatro abas: **Configurações de Etapa**, **Laser**, +**Pós-Processamento** e **Peças de trabalho**. As configurações são descritas em ordem de aba +abaixo. ### Configurações de Frente de Onda diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/contour.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/contour.md index ed5156b72..8aafa05ca 100644 --- a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/contour.md +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/contour.md @@ -48,8 +48,8 @@ ## Основні налаштування -Діалогове вікно налаштувань кроку має три вкладки: **Налаштування кроку**, **Лазер** та -**Постобробка**. Налаштування описані нижче у порядку вкладок. +Діалогове вікно налаштувань кроку має чотири вкладки: **Налаштування кроку**, **Лазер**, +**Постобробка** та **Робочі деталі**. Налаштування описані нижче у порядку вкладок. ### Налаштування контуру diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/frame-outline.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/frame-outline.md index 05a3c66b3..12425136b 100644 --- a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/frame-outline.md +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/frame-outline.md @@ -50,8 +50,8 @@ ## Основні налаштування -Діалогове вікно налаштувань кроку має три вкладки: **Налаштування кроку**, **Лазер** та -**Постобробка**. Налаштування описані нижче у порядку вкладок. +Діалогове вікно налаштувань кроку має чотири вкладки: **Налаштування кроку**, **Лазер**, +**Постобробка** та **Робочі деталі**. Налаштування описані нижче у порядку вкладок. ### Геометрія 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 74ca0a737..d19d9b60c 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 @@ -70,6 +70,15 @@ description: залежно від обраного режиму. Для режимів, які фіксують один із параметрів (наприклад, швидкість у режимі «Потужність проти проходів»), тут також можна задати це константне значення. +## Пов’язана робоча деталь + +Зазвичай операція обробляє всі робочі деталі свого шару, і вкладка **Робочі деталі** її діалогового +вікна налаштувань перелічує їх усі як включені. Тестова сітка матеріалу є винятком: її операція +пов’язана зі створеною нею робочою деталлю та обробляє лише її. На вкладці Робочі деталі створена +сітка показана як єдина включена робоча деталь, а все інше на шарі позначено як виключене. + +![Вкладка Робочі деталі налаштувань тестової сітки матеріалу](/screenshots/step-settings-material-test-grid-workpieces.webp) + ## Розуміння компонування У режимі за замовчуванням «Потужність проти швидкості» потужність зростає зліва направо, а швидкість diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/shrink-wrap.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/shrink-wrap.md index c371d7ac1..9b57151c0 100644 --- a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/shrink-wrap.md +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/shrink-wrap.md @@ -63,8 +63,8 @@ ## Основні налаштування -Діалогове вікно налаштувань кроку має три вкладки: **Налаштування кроку**, **Лазер** та -**Постобробка**. Налаштування описані нижче у порядку вкладок. +Діалогове вікно налаштувань кроку має чотири вкладки: **Налаштування кроку**, **Лазер**, +**Постобробка** та **Робочі деталі**. Налаштування описані нижче у порядку вкладок. ### Обтікання diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/wavefront.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/wavefront.md index aac838e13..aa509fa83 100644 --- a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/wavefront.md +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/wavefront.md @@ -59,8 +59,8 @@ ## Основні параметри -Діалогове вікно налаштувань кроку має три вкладки: **Налаштування кроку**, **Лазер** та -**Постобробка**. Налаштування описані нижче у порядку вкладок. +Діалогове вікно налаштувань кроку має чотири вкладки: **Налаштування кроку**, **Лазер**, +**Постобробка** та **Робочі деталі**. Налаштування описані нижче у порядку вкладок. ### Налаштування хвильового фронту diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/contour.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/contour.md index a31f024a4..fd70aed0d 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/contour.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/contour.md @@ -47,7 +47,7 @@ ## 关键设置 -步骤设置对话框包含三个选项卡:**步骤设置**、**激光**和**后处理**。设置按选项卡顺序说明如下。 +步骤设置对话框包含四个选项卡:**步骤设置**、**激光**、**后处理**和**工件**。设置按选项卡顺序说明如下。 ### 轮廓设置 diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/frame-outline.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/frame-outline.md index acb317f9d..1e3f18ef6 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/frame-outline.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/frame-outline.md @@ -49,7 +49,7 @@ ## 关键设置 -步骤设置对话框包含三个选项卡:**步骤设置**、**激光**和**后处理**。设置按选项卡顺序说明如下。 +步骤设置对话框包含四个选项卡:**步骤设置**、**激光**、**后处理**和**工件**。设置按选项卡顺序说明如下。 ### 几何形状 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 33000763d..4ef172265 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 @@ -49,6 +49,12 @@ mm/min),速度值会以您偏好的显示单位显示。 **参数** 部分保存网格要变化的范围——速度、功率、通过次数或偏移量,取决于所选模式。固定某个参数的模式(例如功率与通过次数中的速度)也允许您在此处设置该常量。 +## 关联的工件 + +通常,一个操作会处理其图层上的所有工件,其设置对话框的**工件**选项卡会将它们全部列为包含。材料测试网格是个例外:它的操作与它生成的工件绑定,只处理该工件。在工件选项卡中,生成的网格是唯一被包含的工件,图层上的其他工件都被标记为排除。 + +![材料测试网格设置中的工件选项卡](/screenshots/step-settings-material-test-grid-workpieces.webp) + ## 理解布局 在默认的功率与速度模式下,功率从左到右增加,速度从上到下增加: diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/shrink-wrap.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/shrink-wrap.md index 86677a719..342661124 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/shrink-wrap.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/shrink-wrap.md @@ -62,7 +62,7 @@ ## 关键设置 -步骤设置对话框包含三个选项卡:**步骤设置**、**激光**和**后处理**。设置按选项卡顺序说明如下。 +步骤设置对话框包含四个选项卡:**步骤设置**、**激光**、**后处理**和**工件**。设置按选项卡顺序说明如下。 ### 收缩包裹 diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/wavefront.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/wavefront.md index 12f01955c..a51c51988 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/wavefront.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/wavefront.md @@ -50,7 +50,7 @@ ## 关键设置 -步骤设置对话框包含三个选项卡:**步骤设置**、**激光**和**后处理**。设置按选项卡顺序说明如下。 +步骤设置对话框包含四个选项卡:**步骤设置**、**激光**、**后处理**和**工件**。设置按选项卡顺序说明如下。 ### 波前设置 diff --git a/website/static/screenshots/step-settings-material-test-grid-workpieces.webp b/website/static/screenshots/step-settings-material-test-grid-workpieces.webp new file mode 100644 index 0000000000000000000000000000000000000000..22202f026c810632a6aac42cc37bf9ee715de810 GIT binary patch literal 11716 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 z zRI6L+m^|p+@7+M!2O>zLQr~{fW!9ET#$ym^3_dV!UVTz^<)_>%SWqRa4evZ;t{D&V zXXu%Bq!|XXsTqR~s}?;~?;C4J^$vikiC3BozgTSr)ccCQUbdEunXVt!)_A)}9*7u@ zs!rZfH1|F~oSccab|E#7YEfMD-p!D-Qy17YK#_Xq@ z)Fbp=#207oO!YSzo=F=TH`rtx*ySG&G5s~90V*jaC|C#vj~P}+P?5^*PAPe)Sn@Ks zsNDZz+Q;J-ADi!16pd^ozXKOiZ11C4N!%9TrC1vyM%vVKRqy?HKBdyp`{}Xd5+%@= zS;r-}VGzI;sPC+A$26|LrvwW&?DaDqL1RM^dGuxs*epH@Af z(UGgilON`$MW2eoXzh^)&#TG@ob_a9?rEKqhv~#3C(@6MsJB-ynNxdb;Cf3u)#LL0;@gS?9a;22w3pt%#=%G9=cNtFAe#3=^+AX6#`E+ zYa91<7|!P^6G+SHfEA4EPj*_%(aLW2&{Dc`(jEUYo~3+jtpETyIR?O&e-bPJ0|E*F z@elo{5p{^e2sw}%A) zQs4m4|K`{@*x4Xb{})62(*P*??>IXf8${9nYW)2C7upp4uf~{vrl9_doBz@OQ~nR6 Cz*@@y literal 0 HcmV?d00001 From 1aab4de3b66c98d5b368d57201fc559d2c89f52b Mon Sep 17 00:00:00 2001 From: Samuel Abels Date: Thu, 15 Oct 2026 14:26:10 +0200 Subject: [PATCH 093/104] fix(pipeline): stamp workpiece artifacts with their generation size An output computed before a workpiece resize can arrive on the main thread after the resize was committed. _on_wp_output stamped such an artifact with the workpiece's live size instead of the size its ops were generated at, so the canvas mapped the ops overlay by current_size / generation_size == 1 and drew the stale ops 1:1. The material test grid's cells and labels then appeared misaligned with the workpiece image until the next rebuild delivered a fresh artifact. Stamp the artifact from the output's own source_dimensions, which every assembler step already populates with the size the ops were generated against, and keep the live size only as a fallback for steps that provide none. --- rayforge/pipeline/pipeline.py | 10 +- tests/pipeline/test_material_test_resize.py | 127 ++++++++++++++++++++ 2 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 tests/pipeline/test_material_test_resize.py diff --git a/rayforge/pipeline/pipeline.py b/rayforge/pipeline/pipeline.py index 0551433af..7b64909d3 100644 --- a/rayforge/pipeline/pipeline.py +++ b/rayforge/pipeline/pipeline.py @@ -373,7 +373,15 @@ def _on_wp_output( if self._is_shutting_down or output is None: return source_dims = output.source_dimensions - gen_size = workpiece.size if workpiece else (0.0, 0.0) + # generation_size must be the size the ops were generated at: + # an output built before a resize can arrive on the main thread + # after the resize was committed, and consumers scale the ops + # by current_size / generation_size. + gen_size = ( + source_dims + if source_dims + else (workpiece.size if workpiece else (0.0, 0.0)) + ) artifact = WorkPieceArtifact( ops=output.ops, is_scalable=output.is_scalable, diff --git a/tests/pipeline/test_material_test_resize.py b/tests/pipeline/test_material_test_resize.py new file mode 100644 index 000000000..f46a36a22 --- /dev/null +++ b/tests/pipeline/test_material_test_resize.py @@ -0,0 +1,127 @@ +"""Regression tests for material-test-grid ops across workpiece resizes. + +The canvas stretches a workpiece's ops view by +``current workpiece size / generation_size``, so ``generation_size`` +must always describe the coordinate space the ops were generated in — +never the workpiece's live size at the moment the output happens to +arrive on the main thread. +""" + +import asyncio +from types import SimpleNamespace + +import pytest + +from rayforge.core.doc import Doc +from rayforge.core.step_registry import step_registry +from rayforge.core.workpiece import WorkPiece +from rayforge.pipeline.pipeline import Pipeline + + +@pytest.fixture(autouse=True) +def _zero_debounce(zero_debounce_delay): + pass + + +@pytest.fixture +def doc(): + d = Doc() + assert d.active_layer.workflow is not None + d.active_layer.workflow.set_steps([]) + return d + + +async def _settle(pipeline: Pipeline, timeout: float = 10.0) -> None: + deadline = asyncio.get_running_loop().time() + timeout + while pipeline.is_busy and asyncio.get_running_loop().time() < deadline: + await asyncio.sleep(0.02) + assert not pipeline.is_busy, "pipeline did not settle" + + +@pytest.fixture +def material_test_setup( + doc, task_mgr, test_machine_and_config, context_initializer +): + """A doc with a material test step and an 80x80 workpiece, plus a + settled pipeline.""" + _machine, _config = test_machine_and_config + step_cls = step_registry.get("MaterialTestStep") + assert step_cls is not None + step = step_cls.create(context_initializer) + + workpiece = WorkPiece(name="mtg") + workpiece.set_size(80.0, 80.0) + doc.active_layer.add_workpiece(workpiece) + step.workpiece_uids = [workpiece.uid] + doc.active_layer.workflow.add_step(step) + + pipeline = Pipeline( + doc, + task_mgr, + context_initializer.artifact_store, + context_initializer.machine, + ) + return doc, pipeline, step, workpiece + + +class TestMaterialTestResize: + @pytest.mark.asyncio + async def test_resize_regenerates_ops_at_new_size( + self, material_test_setup + ): + """A resize followed by the app's pause/resume sequence must + regenerate the ops for the new size.""" + _doc, pipeline, step, workpiece = material_test_setup + await _settle(pipeline) + + artifact = pipeline.get_artifact(step, workpiece) + assert artifact.source_dimensions == pytest.approx((80.0, 80.0)) + + pipeline.pause() + workpiece.set_size(120.0, 60.0) + pipeline.resume() + await _settle(pipeline) + + artifact = pipeline.get_artifact(step, workpiece) + assert artifact.source_dimensions == pytest.approx((120.0, 60.0)) + rect = artifact.ops.rect() + assert rect[2] == pytest.approx(120.0, abs=0.5) + assert rect[3] <= 60.0 + + await asyncio.to_thread(pipeline.task_manager.wait_until_settled, 5000) + + @pytest.mark.asyncio + async def test_late_output_keeps_generation_time_size( + self, material_test_setup + ): + """An output computed before a resize but delivered after it must + carry the size its ops were generated at, not the workpiece's + live size.""" + _doc, pipeline, step, workpiece = material_test_setup + await _settle(pipeline) + + stale = pipeline.get_artifact(step, workpiece) + stale_output = SimpleNamespace( + ops=stale.ops, + is_scalable=stale.is_scalable, + source_dimensions=stale.source_dimensions, + warnings=[], + ) + + pipeline.pause() + workpiece.set_size(120.0, 60.0) + pipeline._on_wp_output( + pipeline, + step=step, + workpiece=workpiece, + output=stale_output, + generation_id=pipeline.data_generation_id, + ) + + stored = pipeline.get_artifact(step, workpiece) + assert stored.source_dimensions == pytest.approx((80.0, 80.0)) + assert stored.generation_size == pytest.approx((80.0, 80.0)) + + pipeline.resume() + await _settle(pipeline) + await asyncio.to_thread(pipeline.task_manager.wait_until_settled, 5000) From 5b5c02cbe1978d930ef8ba9e4ffa7f3317e5be4e Mon Sep 17 00:00:00 2001 From: Samuel Abels Date: Thu, 15 Oct 2026 18:44:16 +0200 Subject: [PATCH 094/104] chore(deps): bump raygeo to 1.66.0 raygeo 1.66.0 limits full-sweep raster passes to each scan line's own content extent instead of the image bounding box (raygeo#62), fixing barebaric/rayforge#565: a shape that narrows in X (e.g. an hourglass) no longer sweeps the widest extent on every row. No rayforge code changes are needed; the behaviour lives entirely in the raster assembler. Re-locks pixi.lock (raygeo-only change). --- debian/requirements-bundle.txt | 2 +- pixi.lock | 90 +++++++++++++++++----------------- pixi.toml | 2 +- requirements.txt | 2 +- 4 files changed, 48 insertions(+), 48 deletions(-) diff --git a/debian/requirements-bundle.txt b/debian/requirements-bundle.txt index da271cdd5..4a37351fd 100644 --- a/debian/requirements-bundle.txt +++ b/debian/requirements-bundle.txt @@ -1,7 +1,7 @@ asyncudp==0.11.0 pyvips==3.2.0 raydriver==0.3.0 -raygeo==1.65.0 +raygeo==1.66.0 ruida-pa==0.22.0 trimesh==5.1.1 vtracer==0.6.15 \ No newline at end of file diff --git a/pixi.lock b/pixi.lock index dba89cd50..467bf6e67 100644 --- a/pixi.lock +++ b/pixi.lock @@ -253,6 +253,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/65/a9/55effa83e64b9ff4f379d9186236d50d03f6d4770d8346805c1b6620c370/fonttools-4.66.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/6f/34/8a43ea689fb44533a55dfebb7c421361cca038d72db69f09b89fa76a3f36/raygeo-1.66.0.tar.gz - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/77/c6/040cbc72480d789a5f40d63fb484d3106554c4dfa2d2b70ad5022057750f/webencodings-0.6.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/80/c9/ddb896767808e3e022453d8dfae26fd52ed404b0aa6fb7f752d39c040208/msgpack-1.2.3-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl @@ -276,7 +277,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/ae/d4/22cbe288c0d5cef7620503be92c0098d82220353fc7e188034a19c517240/websockets-17.2-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/b4/ac/62550bb42cb177a37ff86638670737404dde9417762770a309ddee84fc52/raygeo-1.65.0.tar.gz - pypi: https://files.pythonhosted.org/packages/b5/55/fdd01894807d20fb6181f13590ae247585b10e909b42e6ada833379fe520/plumbum-2.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bd/59/6b1daa3b94de8970e2a2787ba73616c2d0675d2f948ef4cad8bef7f21bc6/cssselect2-0.10.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/be/02/bce7a1d70e9d0f6ea8623c16386138c251b5855af6cd3a11433d3fba4e66/semver-3.1.0-py3-none-any.whl @@ -466,6 +466,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zlib-1.3.2-h8088a28_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hf451053_7.conda - pypi: ./ + - pypi: https://files.pythonhosted.org/packages/02/fc/06c7952ef808bcfb1ab730d464a20fc8f1225d502b960320e9d4fe39344b/raygeo-1.66.0-cp311-abi3-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/03/43/344fbf03470b38c11c334fd1ea9f12212036c93f64887d1f31672a95fd8d/ruida_pa-0.22.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz - pypi: https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl @@ -524,7 +525,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/fa/48/f36d233ce749d1bb880cb5e6f8bb171dc2effabc7435c31da604d831b3ec/gitpython-3.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/fd/43/7c4f803e73d12c9e01c65865964d08ca7e5fced779b2a2692b64c5ec6ce5/raygeo-1.65.0-cp311-abi3-macosx_11_0_arm64.whl build: channels: - url: https://conda.anaconda.org/conda-forge/ @@ -936,6 +936,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/65/a9/55effa83e64b9ff4f379d9186236d50d03f6d4770d8346805c1b6620c370/fonttools-4.66.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/6f/34/8a43ea689fb44533a55dfebb7c421361cca038d72db69f09b89fa76a3f36/raygeo-1.66.0.tar.gz - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/77/c6/040cbc72480d789a5f40d63fb484d3106554c4dfa2d2b70ad5022057750f/webencodings-0.6.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/80/c9/ddb896767808e3e022453d8dfae26fd52ed404b0aa6fb7f752d39c040208/msgpack-1.2.3-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl @@ -961,7 +962,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/ae/d4/22cbe288c0d5cef7620503be92c0098d82220353fc7e188034a19c517240/websockets-17.2-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/b4/ac/62550bb42cb177a37ff86638670737404dde9417762770a309ddee84fc52/raygeo-1.65.0.tar.gz - pypi: https://files.pythonhosted.org/packages/b5/55/fdd01894807d20fb6181f13590ae247585b10e909b42e6ada833379fe520/plumbum-2.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b5/d4/8e58a7221419bf8cb194f362ab26abf504c983135975e4c785fac0852886/flake8-7.4.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bd/59/6b1daa3b94de8970e2a2787ba73616c2d0675d2f948ef4cad8bef7f21bc6/cssselect2-0.10.1-py3-none-any.whl @@ -1167,6 +1167,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hf451053_7.conda - pypi: ./ - pypi: https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/02/fc/06c7952ef808bcfb1ab730d464a20fc8f1225d502b960320e9d4fe39344b/raygeo-1.66.0-cp311-abi3-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/03/43/344fbf03470b38c11c334fd1ea9f12212036c93f64887d1f31672a95fd8d/ruida_pa-0.22.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz @@ -1243,7 +1244,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/fa/70/fffc9613501877c0a10a1ef73a165e6ded3e53f6e7502227fef56d973933/identify-2.6.20-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/fd/43/7c4f803e73d12c9e01c65865964d08ca7e5fced779b2a2692b64c5ec6ce5/raygeo-1.65.0-cp311-abi3-macosx_11_0_arm64.whl video: channels: - url: https://conda.anaconda.org/conda-forge/ @@ -9494,7 +9494,7 @@ packages: name: rayforge requires_dist: - raydriver==0.3.0 - - raygeo==1.65.0 + - raygeo==1.66.0 - aiohttp==3.14.5 - asyncudp==0.11.0 - blinker==1.9.0 @@ -9531,6 +9531,26 @@ packages: name: distlib version: 0.4.3 sha256: 4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b +- pypi: https://files.pythonhosted.org/packages/02/fc/06c7952ef808bcfb1ab730d464a20fc8f1225d502b960320e9d4fe39344b/raygeo-1.66.0-cp311-abi3-macosx_11_0_arm64.whl + name: raygeo + version: 1.66.0 + sha256: 1d6b1db07abff0dac5aeadd39836d549604362a9ac3bc09d268067410b02c1d5 + requires_dist: + - msgpack + - numpy>=1.20.0 + - matplotlib ; extra == 'cli' + - matplotlib ; extra == 'docs' + - mdformat ; extra == 'docs' + - mdformat-frontmatter ; extra == 'docs' + - mdformat-tables ; extra == 'docs' + - pillow ; extra == 'docs' + - pycairo ; extra == 'test' + - pytest ; extra == 'test' + - pytest-mock ; extra == 'test' + - streamlit ; extra == 'test' + - streamlit ; extra == 'visual' + - matplotlib ; extra == 'visual' + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/03/43/344fbf03470b38c11c334fd1ea9f12212036c93f64887d1f31672a95fd8d/ruida_pa-0.22.0-py3-none-any.whl name: ruida-pa version: 0.22.0 @@ -9854,6 +9874,26 @@ packages: version: '17.2' sha256: 3117abfd32b183bdb6194df9317766d32c6517f3d1c0aa8c62d5c6ccfda0b4a8 requires_python: '>=3.11' +- pypi: https://files.pythonhosted.org/packages/6f/34/8a43ea689fb44533a55dfebb7c421361cca038d72db69f09b89fa76a3f36/raygeo-1.66.0.tar.gz + name: raygeo + version: 1.66.0 + sha256: 4e48a8a4be98dbb32ca793a0f8e83063910ae2e3f9d7a8c6c28dc05cb0c2ebaa + requires_dist: + - msgpack + - numpy>=1.20.0 + - pycairo ; extra == 'test' + - pytest ; extra == 'test' + - pytest-mock ; extra == 'test' + - streamlit ; extra == 'test' + - streamlit ; extra == 'visual' + - matplotlib ; extra == 'visual' + - matplotlib ; extra == 'docs' + - mdformat ; extra == 'docs' + - mdformat-frontmatter ; extra == 'docs' + - mdformat-tables ; extra == 'docs' + - pillow ; extra == 'docs' + - matplotlib ; extra == 'cli' + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl name: aiohappyeyeballs version: 2.7.1 @@ -10167,26 +10207,6 @@ packages: - pytest-timeout ; extra == 'testing' - requests ; extra == 'testing' requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/b4/ac/62550bb42cb177a37ff86638670737404dde9417762770a309ddee84fc52/raygeo-1.65.0.tar.gz - name: raygeo - version: 1.65.0 - sha256: 1c893c9f6d27f042d947ec1b62798e8457ef061a6f6d9997d5ec5a8fc5a0a4e0 - requires_dist: - - msgpack - - numpy>=1.20.0 - - pycairo ; extra == 'test' - - pytest ; extra == 'test' - - pytest-mock ; extra == 'test' - - streamlit ; extra == 'test' - - streamlit ; extra == 'visual' - - matplotlib ; extra == 'visual' - - matplotlib ; extra == 'docs' - - mdformat ; extra == 'docs' - - mdformat-frontmatter ; extra == 'docs' - - mdformat-tables ; extra == 'docs' - - pillow ; extra == 'docs' - - matplotlib ; extra == 'cli' - requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/b5/55/fdd01894807d20fb6181f13590ae247585b10e909b42e6ada833379fe520/plumbum-2.0.2-py3-none-any.whl name: plumbum version: 2.0.2 @@ -10555,23 +10575,3 @@ packages: - tree-sitter-yaml>=0.6.0 ; python_full_version >= '3.10' and extra == 'syntax' - typing-extensions>=4.4.0,<5.0.0 requires_python: '>=3.9,<4.0' -- pypi: https://files.pythonhosted.org/packages/fd/43/7c4f803e73d12c9e01c65865964d08ca7e5fced779b2a2692b64c5ec6ce5/raygeo-1.65.0-cp311-abi3-macosx_11_0_arm64.whl - name: raygeo - version: 1.65.0 - sha256: a6d6bbc755c0963849d825ac2936da1fe4ea6f666e87e6c8ca450a612b9180ff - requires_dist: - - msgpack - - numpy>=1.20.0 - - matplotlib ; extra == 'cli' - - matplotlib ; extra == 'docs' - - mdformat ; extra == 'docs' - - mdformat-frontmatter ; extra == 'docs' - - mdformat-tables ; extra == 'docs' - - pillow ; extra == 'docs' - - pycairo ; extra == 'test' - - pytest ; extra == 'test' - - pytest-mock ; extra == 'test' - - streamlit ; extra == 'test' - - streamlit ; extra == 'visual' - - matplotlib ; extra == 'visual' - requires_python: '>=3.10' diff --git a/pixi.toml b/pixi.toml index e942d8266..c06d8149f 100644 --- a/pixi.toml +++ b/pixi.toml @@ -38,7 +38,7 @@ rust = "==1.99.0" patchelf = "==0.19.2" [feature.app.pypi-dependencies] -raygeo = "==1.65.0" +raygeo = "==1.66.0" raydriver = "==0.3.0" rayforge = { editable = true, path = "." } ruida-pa = "==0.22.0" diff --git a/requirements.txt b/requirements.txt index a07ba38e4..779acbc2a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ raydriver==0.3.0 -raygeo==1.65.0 +raygeo==1.66.0 aiohttp==3.14.5 asyncudp==0.11.0 blinker==1.9.0 From cb4c98db53bf8e1b36edad226778bcb9717e6495 Mon Sep 17 00:00:00 2001 From: Samuel Abels Date: Fri, 16 Oct 2026 02:38:39 +0200 Subject: [PATCH 095/104] prepare translations, changelog and metainfo for release 1.13.0-beta1 --- CHANGELOG.md | 53 +++++- data/org.rayforge.rayforge.metainfo.xml | 141 ++++++++++++++++ .../locale/de/LC_MESSAGES/post_processors.po | 39 ++++- .../locale/en/LC_MESSAGES/post_processors.po | 35 +++- .../locale/es/LC_MESSAGES/post_processors.po | 37 ++++- .../locale/fr/LC_MESSAGES/post_processors.po | 37 ++++- .../locale/hi/LC_MESSAGES/post_processors.po | 35 +++- .../locale/post_processors.pot | 35 +++- .../locale/pt/LC_MESSAGES/post_processors.po | 37 ++++- .../locale/uk/LC_MESSAGES/post_processors.po | 37 ++++- .../zh_CN/LC_MESSAGES/post_processors.po | 35 +++- rayforge/locale/de/LC_MESSAGES/rayforge.po | 22 ++- rayforge/locale/en/LC_MESSAGES/rayforge.po | 20 ++- rayforge/locale/es/LC_MESSAGES/rayforge.po | 22 ++- rayforge/locale/fr/LC_MESSAGES/rayforge.po | 22 ++- rayforge/locale/hi/LC_MESSAGES/rayforge.po | 22 ++- rayforge/locale/pt/LC_MESSAGES/rayforge.po | 22 ++- rayforge/locale/rayforge.pot | 20 ++- rayforge/locale/uk/LC_MESSAGES/rayforge.po | 22 ++- rayforge/locale/zh_CN/LC_MESSAGES/rayforge.po | 22 ++- ...eta1-import-formats-dithering-rust-grbl.md | 153 ++++++++++++++++++ website/docs/features/framing-your-job.md | 5 + .../current/features/framing-your-job.md | 6 + .../current/features/framing-your-job.md | 6 + .../current/features/framing-your-job.md | 6 + .../current/features/framing-your-job.md | 5 + .../current/machine/advanced.md | 7 +- .../current/features/framing-your-job.md | 6 + .../current/features/framing-your-job.md | 6 + .../current/features/framing-your-job.md | 2 + 30 files changed, 886 insertions(+), 31 deletions(-) create mode 100644 website/blog/2026-10-16-rayforge-1-13-beta1-import-formats-dithering-rust-grbl.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e7b2a132..317c6657a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to Rayforge will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## UNRELEASED +## 1.13.0-beta1 ### Added @@ -24,12 +24,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Import: compressed SVG (`.svgz`, Inkscape's "Compressed SVG"), with the same options as plain SVG. The file type is registered with the desktop, AppStream and the Windows installer (#546) +- Import: sources can be reloaded from disk in place and relinked to + another file, updating every workpiece made from them as one undo + step. Rayforge watches imported files and offers to reload those + that changed, with an Auto-reload option and a General preference + to turn watching off (#513, #537) - Engrave: more dithering algorithms for photos. The error-diffusion kernels Atkinson, Stucki, Jarvis-Judice-Ninke, Sierra (3-row, 2-row, Lite) and Burkes, with an optional serpentine scan, plus a clustered-dot Newsprint screen and a round-dot Halftone screen with adjustable cell size and angle. LightBurn imports keep the image layer's dither mode (#514) +- Laser: the Frame button opens a popover with an "Include Overscan + Margin" switch. When it is off the frame traces only the envelope + the laser burns instead of the full machine path, so it no longer + overshoots the artwork by the raster overscan margin (raygeo 1.65.0, + #563) - Path clean-up tools in the Object menu and canvas context menu: Close Paths and Join Open Paths with a tolerance in mm, Delete Duplicates (duplicate contours inside a workpiece and stacked copies @@ -56,6 +66,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 selector (Auto, Network, USB) that shows only the fields relevant to the chosen transport; Auto opens USB when available and falls back to the network +- Ruida: network connections can use TCP alongside UDP; ruida-pa was + upgraded to 0.22.0 (thanks to @medoix, #520) +- Machine: a Z homing mode lets machines choose between a Home Switch + and the auto-focus Probe routine where the driver supports both. + The Ruida driver routes Z home to its auto-focus routine by default, + so existing machines keep homing Z as before (#548) +- Machine: a speed-dependent bidirectional scan offset table in the + Advanced settings interpolates the raster offset for each step's + speed, for lasers whose left-to-right/right-to-left skew grows with + speed; a per-step offset still overrides the table (#307) +- Camera: the camera view can extend beyond the work area, with + adjustable margins and border transparency (#543) +- Settings: a new Mouse Gestures page rebinds the 2D and 3D canvas + and sketch-editor navigation gestures, helping with setups where + specific mouse buttons are not delivered (#390) - Linux: official AppImage builds are attached to GitHub releases - Windows: Publish Rayforge through WinGet ("winget install rayforge") - Image Editing addon: non-destructive image edits stored per @@ -78,7 +103,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Engrave: Invert moved from the operation to the workpiece, as the Invert Image edit. Older projects are converted when they are opened, so their output stays the same (#515) - - Machines: the GRBL Serial driver now runs its entire protocol stack (handshake, character-counting flow control, streaming, stall detection and deadlock recovery) in Rust through the raydriver @@ -92,6 +116,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 hook is invoked again when an addon is enabled while the window exists, so addons no longer need `action_extension_registry` for either +- Upgrade raygeo to 1.66.0: full-sweep raster passes are limited to + each scan line's own content extent, so a shape that narrows in X + (for example an hourglass) no longer sweeps the widest extent on + every row (#565) - Updated translations ### Fixed @@ -99,13 +127,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Addon API: disabling an addon did not reliably remove its `action_extension_registry` handlers when several addons registered same-named handlers, which could leave stale handlers behind or - strip other addons' handlers- Engrave: the step's Bidirectional Scan Offset had no effect since + strip other addons' handlers +- Engrave: the step's Bidirectional Scan Offset had no effect since the pipeline moved to raygeo intents, because the value no longer reached the transformer; changing it now also refreshes the cached toolpath (#307) - Step settings: choice rows backed by an enum (such as the Engrave step's dithering algorithm) now show the step's actual value when the dialog opens and after undo, instead of the first entry +- Machine: the running job ETA is derived from the modeled time + remaining at the acknowledged command frontier instead of the recent + command speed, so it no longer collapses during buffered bursts or + swings on jobs that mix fast and slow phases (#496) +- Ruida: homing a single X or Y axis now jogs only that axis instead + of moving both to the origin (#545) +- Engrave: the Crop to Stock post-processor is now offered on Engrave + steps, so raster content can be clipped to the stock boundary (#508) +- Devices: the Sculpfun S30 focus laser uses constant power (M3), so + it lights while the head is stationary; a dedicated Sculpfun S9 + profile was added (#512) - Ruida: the setup wizard no longer requires the optional USB device and Magic fields, so Next is enabled again once a valid hostname is entered; the Magic field is prefilled with the standard 0x88 @@ -123,6 +163,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 placeholder now pick fonts with proper fallback, fixing garbled text and wrong sizes, especially for non-Latin languages (thanks to @atkaper, #507) +- macOS: adding a camera no longer crashes Rayforge; the app bundle + now declares `NSCameraUsageDescription`, and the first frame waits + for the capture session to settle before reading (#510) +- Material test: resizing the workpiece no longer leaves the grid's + cells and labels misaligned with the workpiece image until the next + rebuild; toolpath outputs are stamped with the size their ops were + generated at instead of the workpiece's live size (#568) ## 1.12.0 diff --git a/data/org.rayforge.rayforge.metainfo.xml b/data/org.rayforge.rayforge.metainfo.xml index a95e46c89..276e47ad8 100644 --- a/data/org.rayforge.rayforge.metainfo.xml +++ b/data/org.rayforge.rayforge.metainfo.xml @@ -77,6 +77,147 @@ + + +

More import formats and a new dithering engine, the Rust + GRBL driver as the default, reloadable sources, sketcher + booleans, configurable mouse gestures, and Ruida networking

+

New Features:

+
    +
  • Import Adobe Illustrator, EPS/PostScript and HPGL plot + files, GIF/TIFF/WebP bitmaps, and compressed SVG (svgz); + EPS and PostScript are converted to PDF with Ghostscript, an + optional dependency, and the page size comes from the EPS + bounding box (#517, #546, by switch87)
  • +
  • Imported sources can be reloaded from disk in place and + relinked to another file, updating every workpiece made from + them as one undo step. Rayforge watches imported files and + offers to reload those that changed, with an Auto-reload + option and a General preference to turn watching off (#513, + #537)
  • +
  • A dithering engine for photos: eight error-diffusion + kernels (Atkinson, Stucki, Jarvis-Judice-Ninke, three Sierra + variants and Burkes), an optional serpentine scan, a + clustered-dot Newsprint screen and a round-dot Halftone + screen with adjustable cell size and angle; LightBurn + imports keep the image layer's dither mode (#514, by + switch87)
  • +
  • The Frame button opens a popover with an Include Overscan + Margin switch; when it is off the frame traces only the + envelope the laser burns instead of the full machine path, + so it no longer overshoots the artwork (#563)
  • +
  • Path clean-up tools in the Object menu and canvas + context menu: Close Paths and Join Open Paths with a + tolerance in mm, Delete Duplicates, and Break Apart, each a + single undo step (#516, by switch87)
  • +
  • A Perforation post-processor on Contour steps cuts in + dashes: the laser fires for a Cut Length and travels a Skip + Length with the laser off along every contour, restarting + with a full cut at each contour's start. Useful for fold + lines, tear-off lines and stitch holes; off by default + (#525, by switch87)
  • +
  • Sketcher: boolean operations. Union, Difference, + Intersection and Exclude bake the selected closed regions + into multi-ring polygons, so difference results cut + correctly; undo/redo restores the original entities and + fills (#398)
  • +
  • Optimize: acceleration-aware scanline merging bridges + parallel cut lines at zero power when the machine's motion + profile makes the merged sweep faster, with a Merge + Scanlines switch and a manual max-gap override + (raygeo 1.60.0)
  • +
  • Ruida: the setup wizard gains a Connection mode selector + (Auto, Network, USB), and network connections can use TCP + alongside UDP; ruida-pa was upgraded to 0.22.0 (#520, by + StevenIsaacs and medoix)
  • +
  • A Z homing mode lets machines choose between a Home + Switch and the auto-focus Probe routine where the driver + supports both; the Ruida driver routes Z home to its + auto-focus routine by default (#548)
  • +
  • A speed-dependent bidirectional scan offset table in the + Advanced settings interpolates the raster offset for each + step's speed, for lasers whose skew grows with speed (#307)
  • +
  • The camera view can extend beyond the work area, with + adjustable margins and border transparency (#543, by + atkaper)
  • +
  • A new Mouse Gestures settings page rebinds the 2D and 3D + canvas and sketch-editor navigation gestures (#390)
  • +
  • Official AppImage builds are attached to GitHub + releases, and Rayforge is published through WinGet
  • +
  • Image Editing addon: non-destructive image edits stored + per workpiece, with Tools > Invert Image and Tools > + Adjust Image for brightness, contrast, gamma and unsharp + mask; the register_image_edits hook (plugin API 23) lets + addons provide their own edits (#515)
  • +
  • A new Workpieces tab in step settings shows which + workpieces a step processes on its layer, including the + selection that generated content like the Material Test Grid + used to hide
  • +
+

Changes:

+
    +
  • Engrave's Invert moved from the operation to the + workpiece, as the Invert Image edit; older projects are + converted when they are opened, so their output stays the + same (#515)
  • +
  • The GRBL Serial driver now runs its entire protocol stack + in Rust through the raydriver library; the experimental + "GRBL (Rust)" driver replaces the old Python implementation + under the familiar "GRBL (Serial)" name, so existing machine + configurations keep working unchanged
  • +
  • Addon API 22: actions registered via register_actions + accept an enabled_when predicate, and the hook is invoked + again when an addon is enabled while the window exists
  • +
  • Upgrade raygeo to 1.66.0: full-sweep raster passes are + limited to each scan line's own content extent, so a shape + that narrows in X (for example an hourglass) no longer sweeps + the widest extent on every row (#565)
  • +
  • Updated translations
  • +
+

Bug Fixes:

+
    +
  • Disabling an addon now reliably removes its + action_extension_registry handlers even when several addons + registered same-named handlers
  • +
  • The Engrave step's Bidirectional Scan Offset had no + effect since the pipeline moved to raygeo intents; changing + it now also refreshes the cached toolpath (#307)
  • +
  • Choice rows backed by an enum now show the step's actual + value when the dialog opens and after undo
  • +
  • The running job ETA is derived from the modeled time + remaining instead of the recent command speed, so it no + longer swings on jobs that mix fast and slow phases (#496)
  • +
  • Ruida: homing a single X or Y axis jogs only that axis + instead of moving both to the origin (#545, by medoix)
  • +
  • The Crop to Stock post-processor is now offered on + Engrave steps, so raster content can be clipped to the + stock boundary (#508)
  • +
  • The Sculpfun S30 focus laser uses constant power (M3), + so it lights while the head is stationary; a dedicated + Sculpfun S9 profile was added (#512)
  • +
  • The Ruida setup wizard no longer requires the optional + USB device and Magic fields, so Next is enabled again once + a valid hostname is entered (#501, #504)
  • +
  • GRBL (Network): a new FluidNC protocol variant uses + FluidNC's native command endpoint over its WebSocket, + replacing the confusing "did not receive a valid HTTP + response" error on ESP3D-style handshakes (#490)
  • +
  • Zipping a machine export no longer fails with "ZIP does + not support timestamps before 1980" for 3D models that carry + such timestamps (#500)
  • +
  • On-canvas text is drawn through Pango instead of Cairo's + toy font API, fixing garbled text and wrong sizes, + especially for non-Latin languages (#507, by atkaper)
  • +
  • macOS: adding a camera no longer crashes Rayforge; the + app bundle declares NSCameraUsageDescription, and the first + frame waits for the capture session to settle (#510)
  • +
  • Material test: resizing the workpiece no longer leaves + the grid's cells and labels misaligned with the workpiece + image until the next rebuild; toolpath outputs are stamped + with the size their ops were generated at (#568)
  • +
+ +

A Command step for injecting custom machine code, plus fixes diff --git a/rayforge/builtin_addons/rayforge-addon-post/locale/de/LC_MESSAGES/post_processors.po b/rayforge/builtin_addons/rayforge-addon-post/locale/de/LC_MESSAGES/post_processors.po index d181b503b..d21196daa 100644 --- a/rayforge/builtin_addons/rayforge-addon-post/locale/de/LC_MESSAGES/post_processors.po +++ b/rayforge/builtin_addons/rayforge-addon-post/locale/de/LC_MESSAGES/post_processors.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-10-03 16:38+0200\n" +"POT-Creation-Date: 2026-10-15 05:47+0200\n" "PO-Revision-Date: 2026-03-18 23:38+0100\n" "Last-Translator: Automatically generated\n" "Language-Team: none\n" @@ -207,6 +207,32 @@ msgstr "Einlaufabstand ändern" msgid "Change Lead-Out Distance" msgstr "Auslaufabstand ändern" +#: post_processors/widgets/perforation_group.py +msgid "Cut Length" +msgstr "Schnittlänge" + +#: post_processors/widgets/perforation_group.py +msgid "Distance the laser fires before each gap" +msgstr "Strecke, die der Laser vor jeder Lücke eingeschaltet bleibt" + +#: post_processors/widgets/perforation_group.py +msgid "Skip Length" +msgstr "Sprunglänge" + +#: post_processors/widgets/perforation_group.py +msgid "Distance travelled with the laser off between cuts" +msgstr "" +"Strecke, die mit ausgeschaltetem Laser zwischen den Schnitten zurückgelegt " +"wird" + +#: post_processors/widgets/perforation_group.py +msgid "Change perforation cut length" +msgstr "Perforations-Schnittlänge ändern" + +#: post_processors/widgets/perforation_group.py +msgid "Change perforation skip length" +msgstr "Perforations-Sprunglänge ändern" + #: post_processors/widgets/merge_lines_group.py msgid "Tolerance" msgstr "Toleranz" @@ -263,6 +289,17 @@ msgid "Merges overlapping lines to avoid double passing." msgstr "" "Führt überlappende Linien zusammen, um doppeltes Bearbeiten zu vermeiden." +#: post_processors/transformers/perforation_transformer.py +msgid "Perforation" +msgstr "Perforation" + +#: post_processors/transformers/perforation_transformer.py +msgid "" +"Cuts in dashes: the laser fires for the cut length and skips the skip length" +msgstr "" +"Schneidet gestrichelt: Der Laser ist für die Schnittlänge eingeschaltet und " +"für die Sprunglänge ausgeschaltet" + #: post_processors/transformers/overscan_transformer.py msgid "Overscan" msgstr "Überlauf" diff --git a/rayforge/builtin_addons/rayforge-addon-post/locale/en/LC_MESSAGES/post_processors.po b/rayforge/builtin_addons/rayforge-addon-post/locale/en/LC_MESSAGES/post_processors.po index 2b725acf4..c24806ffd 100644 --- a/rayforge/builtin_addons/rayforge-addon-post/locale/en/LC_MESSAGES/post_processors.po +++ b/rayforge/builtin_addons/rayforge-addon-post/locale/en/LC_MESSAGES/post_processors.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-10-03 16:38+0200\n" +"POT-Creation-Date: 2026-10-15 05:47+0200\n" "PO-Revision-Date: 2026-03-18 23:38+0100\n" "Last-Translator: Automatically generated\n" "Language-Team: none\n" @@ -199,6 +199,30 @@ msgstr "" msgid "Change Lead-Out Distance" msgstr "" +#: post_processors/widgets/perforation_group.py +msgid "Cut Length" +msgstr "" + +#: post_processors/widgets/perforation_group.py +msgid "Distance the laser fires before each gap" +msgstr "" + +#: post_processors/widgets/perforation_group.py +msgid "Skip Length" +msgstr "" + +#: post_processors/widgets/perforation_group.py +msgid "Distance travelled with the laser off between cuts" +msgstr "" + +#: post_processors/widgets/perforation_group.py +msgid "Change perforation cut length" +msgstr "" + +#: post_processors/widgets/perforation_group.py +msgid "Change perforation skip length" +msgstr "" + #: post_processors/widgets/merge_lines_group.py msgid "Tolerance" msgstr "" @@ -252,6 +276,15 @@ msgstr "" msgid "Merges overlapping lines to avoid double passing." msgstr "" +#: post_processors/transformers/perforation_transformer.py +msgid "Perforation" +msgstr "" + +#: post_processors/transformers/perforation_transformer.py +msgid "" +"Cuts in dashes: the laser fires for the cut length and skips the skip length" +msgstr "" + #: post_processors/transformers/overscan_transformer.py msgid "Overscan" msgstr "Overscan" diff --git a/rayforge/builtin_addons/rayforge-addon-post/locale/es/LC_MESSAGES/post_processors.po b/rayforge/builtin_addons/rayforge-addon-post/locale/es/LC_MESSAGES/post_processors.po index cff26d78e..1404197f8 100644 --- a/rayforge/builtin_addons/rayforge-addon-post/locale/es/LC_MESSAGES/post_processors.po +++ b/rayforge/builtin_addons/rayforge-addon-post/locale/es/LC_MESSAGES/post_processors.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-10-03 16:38+0200\n" +"POT-Creation-Date: 2026-10-15 05:47+0200\n" "PO-Revision-Date: 2026-03-18 23:38+0100\n" "Last-Translator: Automatically generated\n" "Language-Team: none\n" @@ -207,6 +207,30 @@ msgstr "Cambiar distancia de entrada" msgid "Change Lead-Out Distance" msgstr "Cambiar distancia de salida" +#: post_processors/widgets/perforation_group.py +msgid "Cut Length" +msgstr "Longitud de corte" + +#: post_processors/widgets/perforation_group.py +msgid "Distance the laser fires before each gap" +msgstr "Distancia que el láser permanece encendido antes de cada espacio" + +#: post_processors/widgets/perforation_group.py +msgid "Skip Length" +msgstr "Longitud de omisión" + +#: post_processors/widgets/perforation_group.py +msgid "Distance travelled with the laser off between cuts" +msgstr "Distancia recorrida con el láser apagado entre cortes" + +#: post_processors/widgets/perforation_group.py +msgid "Change perforation cut length" +msgstr "Cambiar longitud de corte de perforación" + +#: post_processors/widgets/perforation_group.py +msgid "Change perforation skip length" +msgstr "Cambiar longitud de omisión de perforación" + #: post_processors/widgets/merge_lines_group.py msgid "Tolerance" msgstr "Tolerancia" @@ -264,6 +288,17 @@ msgstr "Fusionar líneas" msgid "Merges overlapping lines to avoid double passing." msgstr "Fusiona líneas superpuestas para evitar pases dobles." +#: post_processors/transformers/perforation_transformer.py +msgid "Perforation" +msgstr "Perforación" + +#: post_processors/transformers/perforation_transformer.py +msgid "" +"Cuts in dashes: the laser fires for the cut length and skips the skip length" +msgstr "" +"Corta a trazos: el láser se enciende durante la longitud de corte y se apaga " +"durante la longitud de omisión" + #: post_processors/transformers/overscan_transformer.py msgid "Overscan" msgstr "Sobrecorrido" diff --git a/rayforge/builtin_addons/rayforge-addon-post/locale/fr/LC_MESSAGES/post_processors.po b/rayforge/builtin_addons/rayforge-addon-post/locale/fr/LC_MESSAGES/post_processors.po index 445349d76..c57bb94f7 100644 --- a/rayforge/builtin_addons/rayforge-addon-post/locale/fr/LC_MESSAGES/post_processors.po +++ b/rayforge/builtin_addons/rayforge-addon-post/locale/fr/LC_MESSAGES/post_processors.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-10-03 16:38+0200\n" +"POT-Creation-Date: 2026-10-15 05:47+0200\n" "PO-Revision-Date: 2026-03-18 23:38+0100\n" "Last-Translator: Automatically generated\n" "Language-Team: none\n" @@ -209,6 +209,30 @@ msgstr "Modifier la distance d'entrée" msgid "Change Lead-Out Distance" msgstr "Modifier la distance de sortie" +#: post_processors/widgets/perforation_group.py +msgid "Cut Length" +msgstr "Longueur de coupe" + +#: post_processors/widgets/perforation_group.py +msgid "Distance the laser fires before each gap" +msgstr "Distance pendant laquelle le laser reste allumé avant chaque espace" + +#: post_processors/widgets/perforation_group.py +msgid "Skip Length" +msgstr "Longueur d'omission" + +#: post_processors/widgets/perforation_group.py +msgid "Distance travelled with the laser off between cuts" +msgstr "Distance parcourue avec le laser éteint entre les coupes" + +#: post_processors/widgets/perforation_group.py +msgid "Change perforation cut length" +msgstr "Modifier la longueur de coupe de la perforation" + +#: post_processors/widgets/perforation_group.py +msgid "Change perforation skip length" +msgstr "Modifier la longueur d'omission de la perforation" + #: post_processors/widgets/merge_lines_group.py msgid "Tolerance" msgstr "Tolérance" @@ -267,6 +291,17 @@ msgstr "Fusionner les lignes" msgid "Merges overlapping lines to avoid double passing." msgstr "Fusionne les lignes se chevauchant pour éviter les passages doubles." +#: post_processors/transformers/perforation_transformer.py +msgid "Perforation" +msgstr "Perforation" + +#: post_processors/transformers/perforation_transformer.py +msgid "" +"Cuts in dashes: the laser fires for the cut length and skips the skip length" +msgstr "" +"Coupe en tirets : le laser s'allume pendant la longueur de coupe et s'éteint " +"pendant la longueur d'omission" + #: post_processors/transformers/overscan_transformer.py msgid "Overscan" msgstr "Surbalayage" diff --git a/rayforge/builtin_addons/rayforge-addon-post/locale/hi/LC_MESSAGES/post_processors.po b/rayforge/builtin_addons/rayforge-addon-post/locale/hi/LC_MESSAGES/post_processors.po index db86626fb..1a7fd9393 100644 --- a/rayforge/builtin_addons/rayforge-addon-post/locale/hi/LC_MESSAGES/post_processors.po +++ b/rayforge/builtin_addons/rayforge-addon-post/locale/hi/LC_MESSAGES/post_processors.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: Rayforge\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-10-03 16:38+0200\n" +"POT-Creation-Date: 2026-10-15 05:47+0200\n" "PO-Revision-Date: 2026-09-27 22:58\n" "Last-Translator: Samuel Abels \n" "Language-Team: none\n" @@ -201,6 +201,30 @@ msgstr "लीड-इन दूरी बदलें" msgid "Change Lead-Out Distance" msgstr "लीड-आउट दूरी बदलें" +#: post_processors/widgets/perforation_group.py +msgid "Cut Length" +msgstr "कट लंबाई" + +#: post_processors/widgets/perforation_group.py +msgid "Distance the laser fires before each gap" +msgstr "प्रत्येक अंतर से पहले लेज़र जितनी दूरी तक चालू रहता है" + +#: post_processors/widgets/perforation_group.py +msgid "Skip Length" +msgstr "स्किप लंबाई" + +#: post_processors/widgets/perforation_group.py +msgid "Distance travelled with the laser off between cuts" +msgstr "कटों के बीच लेज़र बंद रहने पर तय की गई दूरी" + +#: post_processors/widgets/perforation_group.py +msgid "Change perforation cut length" +msgstr "परफ़ोरेशन कट लंबाई बदलें" + +#: post_processors/widgets/perforation_group.py +msgid "Change perforation skip length" +msgstr "परफ़ोरेशन स्किप लंबाई बदलें" + #: post_processors/widgets/merge_lines_group.py msgid "Tolerance" msgstr "सहनशीलता" @@ -254,6 +278,15 @@ msgstr "रेखाएँ मर्ज करें" msgid "Merges overlapping lines to avoid double passing." msgstr "दोहरे पास से बचने के लिए ओवरलैपिंग रेखाएँ मर्ज करता है." +#: post_processors/transformers/perforation_transformer.py +msgid "Perforation" +msgstr "परफ़ोरेशन" + +#: post_processors/transformers/perforation_transformer.py +msgid "" +"Cuts in dashes: the laser fires for the cut length and skips the skip length" +msgstr "डैश में काटता है: लेज़र कट लंबाई तक चालू रहता है और स्किप लंबाई तक बंद रहता है" + #: post_processors/transformers/overscan_transformer.py msgid "Overscan" msgstr "ओवरस्कैन" diff --git a/rayforge/builtin_addons/rayforge-addon-post/locale/post_processors.pot b/rayforge/builtin_addons/rayforge-addon-post/locale/post_processors.pot index 1c533abba..4838195d9 100644 --- a/rayforge/builtin_addons/rayforge-addon-post/locale/post_processors.pot +++ b/rayforge/builtin_addons/rayforge-addon-post/locale/post_processors.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-10-03 16:38+0200\n" +"POT-Creation-Date: 2026-10-15 05:47+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -199,6 +199,30 @@ msgstr "" msgid "Change Lead-Out Distance" msgstr "" +#: post_processors/widgets/perforation_group.py +msgid "Cut Length" +msgstr "" + +#: post_processors/widgets/perforation_group.py +msgid "Distance the laser fires before each gap" +msgstr "" + +#: post_processors/widgets/perforation_group.py +msgid "Skip Length" +msgstr "" + +#: post_processors/widgets/perforation_group.py +msgid "Distance travelled with the laser off between cuts" +msgstr "" + +#: post_processors/widgets/perforation_group.py +msgid "Change perforation cut length" +msgstr "" + +#: post_processors/widgets/perforation_group.py +msgid "Change perforation skip length" +msgstr "" + #: post_processors/widgets/merge_lines_group.py msgid "Tolerance" msgstr "" @@ -252,6 +276,15 @@ msgstr "" msgid "Merges overlapping lines to avoid double passing." msgstr "" +#: post_processors/transformers/perforation_transformer.py +msgid "Perforation" +msgstr "" + +#: post_processors/transformers/perforation_transformer.py +msgid "" +"Cuts in dashes: the laser fires for the cut length and skips the skip length" +msgstr "" + #: post_processors/transformers/overscan_transformer.py msgid "Overscan" msgstr "" diff --git a/rayforge/builtin_addons/rayforge-addon-post/locale/pt/LC_MESSAGES/post_processors.po b/rayforge/builtin_addons/rayforge-addon-post/locale/pt/LC_MESSAGES/post_processors.po index 4148e83f2..381d2d91b 100644 --- a/rayforge/builtin_addons/rayforge-addon-post/locale/pt/LC_MESSAGES/post_processors.po +++ b/rayforge/builtin_addons/rayforge-addon-post/locale/pt/LC_MESSAGES/post_processors.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-10-03 16:38+0200\n" +"POT-Creation-Date: 2026-10-15 05:47+0200\n" "PO-Revision-Date: 2026-03-18 23:38+0100\n" "Last-Translator: Automatically generated\n" "Language-Team: none\n" @@ -206,6 +206,30 @@ msgstr "Alterar distância de entrada" msgid "Change Lead-Out Distance" msgstr "Alterar distância de saída" +#: post_processors/widgets/perforation_group.py +msgid "Cut Length" +msgstr "Comprimento de corte" + +#: post_processors/widgets/perforation_group.py +msgid "Distance the laser fires before each gap" +msgstr "Distância que o laser permanece ligado antes de cada espaço" + +#: post_processors/widgets/perforation_group.py +msgid "Skip Length" +msgstr "Comprimento de salto" + +#: post_processors/widgets/perforation_group.py +msgid "Distance travelled with the laser off between cuts" +msgstr "Distância percorrida com o laser desligado entre cortes" + +#: post_processors/widgets/perforation_group.py +msgid "Change perforation cut length" +msgstr "Alterar comprimento de corte da perfuração" + +#: post_processors/widgets/perforation_group.py +msgid "Change perforation skip length" +msgstr "Alterar comprimento de salto da perfuração" + #: post_processors/widgets/merge_lines_group.py msgid "Tolerance" msgstr "Tolerância" @@ -262,6 +286,17 @@ msgstr "Fundir linhas" msgid "Merges overlapping lines to avoid double passing." msgstr "Funde linhas sobrepostas para evitar passagens duplas." +#: post_processors/transformers/perforation_transformer.py +msgid "Perforation" +msgstr "Perfuração" + +#: post_processors/transformers/perforation_transformer.py +msgid "" +"Cuts in dashes: the laser fires for the cut length and skips the skip length" +msgstr "" +"Corta em traços: o laser liga durante o comprimento de corte e desliga " +"durante o comprimento de salto" + #: post_processors/transformers/overscan_transformer.py msgid "Overscan" msgstr "Sobrescan" diff --git a/rayforge/builtin_addons/rayforge-addon-post/locale/uk/LC_MESSAGES/post_processors.po b/rayforge/builtin_addons/rayforge-addon-post/locale/uk/LC_MESSAGES/post_processors.po index d9d336910..ce3658435 100644 --- a/rayforge/builtin_addons/rayforge-addon-post/locale/uk/LC_MESSAGES/post_processors.po +++ b/rayforge/builtin_addons/rayforge-addon-post/locale/uk/LC_MESSAGES/post_processors.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-10-03 16:38+0200\n" +"POT-Creation-Date: 2026-10-15 05:47+0200\n" "PO-Revision-Date: 2026-03-18 23:38+0100\n" "Last-Translator: Automatically generated\n" "Language-Team: none\n" @@ -205,6 +205,30 @@ msgstr "Змінити відстань в'їзду" msgid "Change Lead-Out Distance" msgstr "Змінити відстань виїзду" +#: post_processors/widgets/perforation_group.py +msgid "Cut Length" +msgstr "Довжина різання" + +#: post_processors/widgets/perforation_group.py +msgid "Distance the laser fires before each gap" +msgstr "Відстань, яку лазер залишається увімкненим перед кожним проміжком" + +#: post_processors/widgets/perforation_group.py +msgid "Skip Length" +msgstr "Довжина пропуску" + +#: post_processors/widgets/perforation_group.py +msgid "Distance travelled with the laser off between cuts" +msgstr "Відстань, пройдена з вимкненим лазером між різаннями" + +#: post_processors/widgets/perforation_group.py +msgid "Change perforation cut length" +msgstr "Змінити довжину різання перфорації" + +#: post_processors/widgets/perforation_group.py +msgid "Change perforation skip length" +msgstr "Змінити довжину пропуску перфорації" + #: post_processors/widgets/merge_lines_group.py msgid "Tolerance" msgstr "Допуск" @@ -261,6 +285,17 @@ msgstr "Об'єднати лінії" msgid "Merges overlapping lines to avoid double passing." msgstr "Об'єднує лінії, що перекриваються, щоб уникнути подвійного проходу." +#: post_processors/transformers/perforation_transformer.py +msgid "Perforation" +msgstr "Перфорація" + +#: post_processors/transformers/perforation_transformer.py +msgid "" +"Cuts in dashes: the laser fires for the cut length and skips the skip length" +msgstr "" +"Ріже пунктиром: лазер увімкнений на довжину різання та вимкнений на довжину " +"пропуску" + #: post_processors/transformers/overscan_transformer.py msgid "Overscan" msgstr "Перебіг" diff --git a/rayforge/builtin_addons/rayforge-addon-post/locale/zh_CN/LC_MESSAGES/post_processors.po b/rayforge/builtin_addons/rayforge-addon-post/locale/zh_CN/LC_MESSAGES/post_processors.po index 5012afa25..af286c324 100644 --- a/rayforge/builtin_addons/rayforge-addon-post/locale/zh_CN/LC_MESSAGES/post_processors.po +++ b/rayforge/builtin_addons/rayforge-addon-post/locale/zh_CN/LC_MESSAGES/post_processors.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-10-03 16:38+0200\n" +"POT-Creation-Date: 2026-10-15 05:47+0200\n" "PO-Revision-Date: 2026-03-18 23:38+0100\n" "Last-Translator: Automatically generated\n" "Language-Team: none\n" @@ -198,6 +198,30 @@ msgstr "更改导入距离" msgid "Change Lead-Out Distance" msgstr "更改导出距离" +#: post_processors/widgets/perforation_group.py +msgid "Cut Length" +msgstr "切割长度" + +#: post_processors/widgets/perforation_group.py +msgid "Distance the laser fires before each gap" +msgstr "激光在每个间隙前保持出光的距离" + +#: post_processors/widgets/perforation_group.py +msgid "Skip Length" +msgstr "跳过长度" + +#: post_processors/widgets/perforation_group.py +msgid "Distance travelled with the laser off between cuts" +msgstr "两次切割之间激光关闭时的行进距离" + +#: post_processors/widgets/perforation_group.py +msgid "Change perforation cut length" +msgstr "更改穿孔切割长度" + +#: post_processors/widgets/perforation_group.py +msgid "Change perforation skip length" +msgstr "更改穿孔跳过长度" + #: post_processors/widgets/merge_lines_group.py msgid "Tolerance" msgstr "容差" @@ -251,6 +275,15 @@ msgstr "合并线条" msgid "Merges overlapping lines to avoid double passing." msgstr "合并重叠线条以避免重复通过。" +#: post_processors/transformers/perforation_transformer.py +msgid "Perforation" +msgstr "穿孔" + +#: post_processors/transformers/perforation_transformer.py +msgid "" +"Cuts in dashes: the laser fires for the cut length and skips the skip length" +msgstr "以虚线方式切割:激光在切割长度内出光,在跳过长度内关闭" + #: post_processors/transformers/overscan_transformer.py msgid "Overscan" msgstr "超扫描" diff --git a/rayforge/locale/de/LC_MESSAGES/rayforge.po b/rayforge/locale/de/LC_MESSAGES/rayforge.po index ebd57c3f0..77e387fe1 100644 --- a/rayforge/locale/de/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/de/LC_MESSAGES/rayforge.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: Rayforge\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-10-15 02:25+0200\n" +"POT-Creation-Date: 2026-10-15 13:51+0200\n" "PO-Revision-Date: 2025-07-24 22:08+0200\n" "Last-Translator: Samuel Abels \n" "Language-Team: none\n" @@ -8831,6 +8831,24 @@ msgstr "Schrittname und Rezepteinstellungen." msgid "Change {key}" msgstr "{key} ändern" +#: rayforge/ui_gtk/doceditor/step_settings/pages/workpieces.py +#: rayforge/ui_gtk/doceditor/step_settings/dialog.py +msgid "Workpieces" +msgstr "Werkstücke" + +#: rayforge/ui_gtk/doceditor/step_settings/pages/workpieces.py +msgid "The workpieces this step processes." +msgstr "Die Werkstücke, die dieser Schritt verarbeitet." + +#: rayforge/ui_gtk/doceditor/step_settings/pages/workpieces.py +msgid "All workpieces on this layer" +msgstr "Alle Werkstücke auf dieser Ebene" + +#: rayforge/ui_gtk/doceditor/step_settings/pages/workpieces.py +#, python-brace-format +msgid "Processes {count} of {total} workpieces" +msgstr "Verarbeitet {count} von {total} Werkstücken" + #: rayforge/ui_gtk/doceditor/step_settings/pages/post_processing.py msgid "Transformers applied to this step's generated toolpath." msgstr "" @@ -10732,7 +10750,7 @@ msgstr "Normalisierung der Bilddaten fehlgeschlagen." #: rayforge/image/svg/importer.py msgid "This SVGZ file's gzip data could not be decompressed." -msgstr "" +msgstr "Die gzip-Daten dieser SVGZ-Datei konnten nicht dekomprimiert werden." #: rayforge/image/bitmap/importer.py #, python-brace-format diff --git a/rayforge/locale/en/LC_MESSAGES/rayforge.po b/rayforge/locale/en/LC_MESSAGES/rayforge.po index b1fab54e3..5943dd5b5 100644 --- a/rayforge/locale/en/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/en/LC_MESSAGES/rayforge.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-10-15 02:25+0200\n" +"POT-Creation-Date: 2026-10-15 13:51+0200\n" "PO-Revision-Date: 2025-07-13 11:49+0200\n" "Last-Translator: Samuel \n" "Language-Team: English\n" @@ -8245,6 +8245,24 @@ msgstr "" msgid "Change {key}" msgstr "" +#: rayforge/ui_gtk/doceditor/step_settings/pages/workpieces.py +#: rayforge/ui_gtk/doceditor/step_settings/dialog.py +msgid "Workpieces" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/pages/workpieces.py +msgid "The workpieces this step processes." +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/pages/workpieces.py +msgid "All workpieces on this layer" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/pages/workpieces.py +#, python-brace-format +msgid "Processes {count} of {total} workpieces" +msgstr "" + #: rayforge/ui_gtk/doceditor/step_settings/pages/post_processing.py msgid "Transformers applied to this step's generated toolpath." msgstr "" diff --git a/rayforge/locale/es/LC_MESSAGES/rayforge.po b/rayforge/locale/es/LC_MESSAGES/rayforge.po index 30cbb2a97..606a2ffff 100644 --- a/rayforge/locale/es/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/es/LC_MESSAGES/rayforge.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: Rayforge\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-10-15 02:25+0200\n" +"POT-Creation-Date: 2026-10-15 13:51+0200\n" "PO-Revision-Date: 2025-08-08 10:00+0200\n" "Last-Translator: Samuel Abels\n" "Language-Team: Spanish \n" @@ -8813,6 +8813,24 @@ msgstr "Nombre del paso y ajustes de la receta." msgid "Change {key}" msgstr "Cambiar {key}" +#: rayforge/ui_gtk/doceditor/step_settings/pages/workpieces.py +#: rayforge/ui_gtk/doceditor/step_settings/dialog.py +msgid "Workpieces" +msgstr "Piezas de trabajo" + +#: rayforge/ui_gtk/doceditor/step_settings/pages/workpieces.py +msgid "The workpieces this step processes." +msgstr "Las piezas de trabajo que procesa este paso." + +#: rayforge/ui_gtk/doceditor/step_settings/pages/workpieces.py +msgid "All workpieces on this layer" +msgstr "Todas las piezas de trabajo de esta capa" + +#: rayforge/ui_gtk/doceditor/step_settings/pages/workpieces.py +#, python-brace-format +msgid "Processes {count} of {total} workpieces" +msgstr "Procesa {count} de {total} piezas de trabajo" + #: rayforge/ui_gtk/doceditor/step_settings/pages/post_processing.py msgid "Transformers applied to this step's generated toolpath." msgstr "" @@ -10706,7 +10724,7 @@ msgstr "Error al normalizar datos de imagen." #: rayforge/image/svg/importer.py msgid "This SVGZ file's gzip data could not be decompressed." -msgstr "" +msgstr "No se pudieron descomprimir los datos gzip de este archivo SVGZ." #: rayforge/image/bitmap/importer.py #, python-brace-format diff --git a/rayforge/locale/fr/LC_MESSAGES/rayforge.po b/rayforge/locale/fr/LC_MESSAGES/rayforge.po index f146ebea5..aef2884dc 100644 --- a/rayforge/locale/fr/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/fr/LC_MESSAGES/rayforge.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: rayforge\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-10-15 02:25+0200\n" +"POT-Creation-Date: 2026-10-15 13:51+0200\n" "PO-Revision-Date: 2025-10-15 07:10+0200\n" "Last-Translator: Samuel \n" "Language-Team: French\n" @@ -8855,6 +8855,24 @@ msgstr "Nom de l'étape et paramètres de la recette." msgid "Change {key}" msgstr "Modifier {key}" +#: rayforge/ui_gtk/doceditor/step_settings/pages/workpieces.py +#: rayforge/ui_gtk/doceditor/step_settings/dialog.py +msgid "Workpieces" +msgstr "Pièces" + +#: rayforge/ui_gtk/doceditor/step_settings/pages/workpieces.py +msgid "The workpieces this step processes." +msgstr "Les pièces que cette étape traite." + +#: rayforge/ui_gtk/doceditor/step_settings/pages/workpieces.py +msgid "All workpieces on this layer" +msgstr "Toutes les pièces de cette couche" + +#: rayforge/ui_gtk/doceditor/step_settings/pages/workpieces.py +#, python-brace-format +msgid "Processes {count} of {total} workpieces" +msgstr "Traite {count} pièces sur {total}" + #: rayforge/ui_gtk/doceditor/step_settings/pages/post_processing.py msgid "Transformers applied to this step's generated toolpath." msgstr "Transformateurs appliqués au parcours d'outil généré par cette étape." @@ -10758,7 +10776,7 @@ msgstr "Échec de la normalisation des données d'image." #: rayforge/image/svg/importer.py msgid "This SVGZ file's gzip data could not be decompressed." -msgstr "" +msgstr "Les données gzip de ce fichier SVGZ n'ont pas pu être décompressées." #: rayforge/image/bitmap/importer.py #, python-brace-format diff --git a/rayforge/locale/hi/LC_MESSAGES/rayforge.po b/rayforge/locale/hi/LC_MESSAGES/rayforge.po index 96a2404e6..f691af7c4 100644 --- a/rayforge/locale/hi/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/hi/LC_MESSAGES/rayforge.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: Rayforge\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-10-15 02:25+0200\n" +"POT-Creation-Date: 2026-10-15 13:51+0200\n" "PO-Revision-Date: 2026-09-27 22:58\n" "Last-Translator: Samuel Abels \n" "Language-Team: none\n" @@ -8535,6 +8535,24 @@ msgstr "स्टेप नाम और रेसिपी सेटिंग msgid "Change {key}" msgstr "{key} बदलें" +#: rayforge/ui_gtk/doceditor/step_settings/pages/workpieces.py +#: rayforge/ui_gtk/doceditor/step_settings/dialog.py +msgid "Workpieces" +msgstr "वर्कपीस" + +#: rayforge/ui_gtk/doceditor/step_settings/pages/workpieces.py +msgid "The workpieces this step processes." +msgstr "वे वर्कपीस जिन्हें यह स्टेप प्रोसेस करता है।" + +#: rayforge/ui_gtk/doceditor/step_settings/pages/workpieces.py +msgid "All workpieces on this layer" +msgstr "इस लेयर की सभी वर्कपीस" + +#: rayforge/ui_gtk/doceditor/step_settings/pages/workpieces.py +#, python-brace-format +msgid "Processes {count} of {total} workpieces" +msgstr "{total} में से {count} वर्कपीस प्रोसेस करता है" + #: rayforge/ui_gtk/doceditor/step_settings/pages/post_processing.py msgid "Transformers applied to this step's generated toolpath." msgstr "इस स्टेप के जनरेट किए गए टूलपाथ पर लागू ट्रांसफ़ॉर्मर." @@ -10396,7 +10414,7 @@ msgstr "छवि डेटा सामान्यीकृत करने #: rayforge/image/svg/importer.py msgid "This SVGZ file's gzip data could not be decompressed." -msgstr "" +msgstr "इस SVGZ फ़ाइल का gzip डेटा डीकंप्रेस नहीं किया जा सका।" #: rayforge/image/bitmap/importer.py #, python-brace-format diff --git a/rayforge/locale/pt/LC_MESSAGES/rayforge.po b/rayforge/locale/pt/LC_MESSAGES/rayforge.po index 89c003847..9e2eff68e 100644 --- a/rayforge/locale/pt/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/pt/LC_MESSAGES/rayforge.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: Rayforge\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-10-15 02:25+0200\n" +"POT-Creation-Date: 2026-10-15 13:51+0200\n" "PO-Revision-Date: 2025-07-24 22:09+0200\n" "Last-Translator: Samuel Abels \n" "Language-Team: Portuguese \n" @@ -8767,6 +8767,24 @@ msgstr "Nome do passo e definições da receita." msgid "Change {key}" msgstr "Alterar {key}" +#: rayforge/ui_gtk/doceditor/step_settings/pages/workpieces.py +#: rayforge/ui_gtk/doceditor/step_settings/dialog.py +msgid "Workpieces" +msgstr "Peças de trabalho" + +#: rayforge/ui_gtk/doceditor/step_settings/pages/workpieces.py +msgid "The workpieces this step processes." +msgstr "As peças de trabalho que esta etapa processa." + +#: rayforge/ui_gtk/doceditor/step_settings/pages/workpieces.py +msgid "All workpieces on this layer" +msgstr "Todas as peças de trabalho desta camada" + +#: rayforge/ui_gtk/doceditor/step_settings/pages/workpieces.py +#, python-brace-format +msgid "Processes {count} of {total} workpieces" +msgstr "Processa {count} de {total} peças de trabalho" + #: rayforge/ui_gtk/doceditor/step_settings/pages/post_processing.py msgid "Transformers applied to this step's generated toolpath." msgstr "" @@ -10656,7 +10674,7 @@ msgstr "Falha ao normalizar dados de imagem." #: rayforge/image/svg/importer.py msgid "This SVGZ file's gzip data could not be decompressed." -msgstr "" +msgstr "Não foi possível descomprimir os dados gzip deste arquivo SVGZ." #: rayforge/image/bitmap/importer.py #, python-brace-format diff --git a/rayforge/locale/rayforge.pot b/rayforge/locale/rayforge.pot index 4898ff6b9..9db362bfe 100644 --- a/rayforge/locale/rayforge.pot +++ b/rayforge/locale/rayforge.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-10-15 02:25+0200\n" +"POT-Creation-Date: 2026-10-15 13:51+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -8246,6 +8246,24 @@ msgstr "" msgid "Change {key}" msgstr "" +#: rayforge/ui_gtk/doceditor/step_settings/pages/workpieces.py +#: rayforge/ui_gtk/doceditor/step_settings/dialog.py +msgid "Workpieces" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/pages/workpieces.py +msgid "The workpieces this step processes." +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/pages/workpieces.py +msgid "All workpieces on this layer" +msgstr "" + +#: rayforge/ui_gtk/doceditor/step_settings/pages/workpieces.py +#, python-brace-format +msgid "Processes {count} of {total} workpieces" +msgstr "" + #: rayforge/ui_gtk/doceditor/step_settings/pages/post_processing.py msgid "Transformers applied to this step's generated toolpath." msgstr "" diff --git a/rayforge/locale/uk/LC_MESSAGES/rayforge.po b/rayforge/locale/uk/LC_MESSAGES/rayforge.po index a73b533b7..9746da976 100644 --- a/rayforge/locale/uk/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/uk/LC_MESSAGES/rayforge.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: Rayforge\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-10-15 02:25+0200\n" +"POT-Creation-Date: 2026-10-15 13:51+0200\n" "PO-Revision-Date: 2026-02-23 01:17+0100\n" "Last-Translator: Samuel Abels \n" "Language-Team: Ukrainian\n" @@ -8731,6 +8731,24 @@ msgstr "Назва кроку та налаштування рецепта." msgid "Change {key}" msgstr "Змінити {key}" +#: rayforge/ui_gtk/doceditor/step_settings/pages/workpieces.py +#: rayforge/ui_gtk/doceditor/step_settings/dialog.py +msgid "Workpieces" +msgstr "Заготовки" + +#: rayforge/ui_gtk/doceditor/step_settings/pages/workpieces.py +msgid "The workpieces this step processes." +msgstr "Заготовки, які обробляє цей крок." + +#: rayforge/ui_gtk/doceditor/step_settings/pages/workpieces.py +msgid "All workpieces on this layer" +msgstr "Усі заготовки на цьому шарі" + +#: rayforge/ui_gtk/doceditor/step_settings/pages/workpieces.py +#, python-brace-format +msgid "Processes {count} of {total} workpieces" +msgstr "Обробляє {count} з {total} заготовок" + #: rayforge/ui_gtk/doceditor/step_settings/pages/post_processing.py msgid "Transformers applied to this step's generated toolpath." msgstr "" @@ -10624,7 +10642,7 @@ msgstr "Не вдалося нормалізувати дані зображен #: rayforge/image/svg/importer.py msgid "This SVGZ file's gzip data could not be decompressed." -msgstr "" +msgstr "Не вдалося розпакувати дані gzip цього файлу SVGZ." #: rayforge/image/bitmap/importer.py #, python-brace-format diff --git a/rayforge/locale/zh_CN/LC_MESSAGES/rayforge.po b/rayforge/locale/zh_CN/LC_MESSAGES/rayforge.po index afca0efec..e87450ff0 100644 --- a/rayforge/locale/zh_CN/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/zh_CN/LC_MESSAGES/rayforge.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: Rayforge\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-10-15 02:25+0200\n" +"POT-Creation-Date: 2026-10-15 13:51+0200\n" "PO-Revision-Date: 2025-07-13 11:49+0200\n" "Last-Translator: Samuel Abels \n" "Language-Team: Chinese (Simplified)\n" @@ -8358,6 +8358,24 @@ msgstr "步骤名称和配方设置。" msgid "Change {key}" msgstr "更改{key}" +#: rayforge/ui_gtk/doceditor/step_settings/pages/workpieces.py +#: rayforge/ui_gtk/doceditor/step_settings/dialog.py +msgid "Workpieces" +msgstr "工件" + +#: rayforge/ui_gtk/doceditor/step_settings/pages/workpieces.py +msgid "The workpieces this step processes." +msgstr "此步骤处理的工件。" + +#: rayforge/ui_gtk/doceditor/step_settings/pages/workpieces.py +msgid "All workpieces on this layer" +msgstr "此图层上的全部工件" + +#: rayforge/ui_gtk/doceditor/step_settings/pages/workpieces.py +#, python-brace-format +msgid "Processes {count} of {total} workpieces" +msgstr "处理 {total} 个工件中的 {count} 个" + #: rayforge/ui_gtk/doceditor/step_settings/pages/post_processing.py msgid "Transformers applied to this step's generated toolpath." msgstr "应用于此步骤生成的刀具路径的转换器。" @@ -10183,7 +10201,7 @@ msgstr "归一化图像数据失败。" #: rayforge/image/svg/importer.py msgid "This SVGZ file's gzip data could not be decompressed." -msgstr "" +msgstr "无法解压缩此 SVGZ 文件的 gzip 数据。" #: rayforge/image/bitmap/importer.py #, python-brace-format diff --git a/website/blog/2026-10-16-rayforge-1-13-beta1-import-formats-dithering-rust-grbl.md b/website/blog/2026-10-16-rayforge-1-13-beta1-import-formats-dithering-rust-grbl.md new file mode 100644 index 000000000..dbe4b4737 --- /dev/null +++ b/website/blog/2026-10-16-rayforge-1-13-beta1-import-formats-dithering-rust-grbl.md @@ -0,0 +1,153 @@ +--- +slug: rayforge-1-13-beta1-import-formats-dithering-rust-grbl +title: "Rayforge 1.13.0-beta1 - More Import Formats, a Dithering Engine, Rust GRBL by Default" +authors: rayforge_team +tags: [release, 1.13, beta, import, dithering, grbl, ruida, sketcher] +description: + "Rayforge 1.13.0-beta1 opens many more file formats, adds a full dithering engine for photos, + makes the Rust GRBL driver the default, and brings reloadable sources, sketcher booleans, and + configurable mouse gestures." +--- + +The first beta of Rayforge 1.13 is here. This cycle is all about the moment your work enters and +leaves Rayforge: opening almost any file your laser software might hand you, engraving it with a lot +more control, and finishing the Rust GRBL migration. A lot of it came from the community. + + + +## Many More File Formats + +Rayforge now opens **Adobe Illustrator (`.ai`), EPS and PostScript** files, **HPGL** plot files from +cutting plotters and CAD programs, **GIF, TIFF and WebP** bitmaps, and **compressed SVG** (`.svgz`, +Inkscape's "Compressed SVG"). AI files saved with PDF compatibility import like PDFs; EPS, +PostScript and older AI files are converted with Ghostscript, an optional dependency, with a clear +message when it is missing. Text becomes outlines and the EPS bounding box sets the page size. + +This is the work of **@switch87** (Gert Pellin) -- more on him below. + +## Reload and Relink Imported Sources + +When an imported file changes on disk, Rayforge now notices. The File menu, the canvas and layer +list context menus, and the asset browser gained **Reload from Disk** and **Relink Source File**. +Reloading replaces a source's data in place and updates every workpiece made from it as one undo +step, so your placement, layers, tabs and settings survive. Files can be marked for **Auto-reload**, +and a General preference turns watching off entirely. + +This pairs nicely with the new imports: EPS, PostScript and converted AI sources now remember the +original file they came from, so "did this change?" still works after a conversion. + +## A Real Dithering Engine + +Engraved photos got a serious upgrade. Rayforge ships **eight error-diffusion kernels** -- Atkinson, +Stucki, Jarvis-Judice-Ninke, three Sierra variants and Burkes -- alongside the classic +Floyd-Steinberg, plus an optional **serpentine scan**. For a different look there are two halftone +screens: a round-dot **Halftone** and a clustered-dot **Newsprint**, each with an adjustable cell +size and angle. If you import files from LightBurn, your dither mode now comes along for the ride. + +The heavy lifting lives in raygeo, our Rust geometry library, so it stays fast even on large images. +Also by @switch87. + +## The Rust GRBL Driver Is Now the Default + +Rayforge 1.12 introduced the experimental "GRBL (Rust)" driver. Since then it has fully replaced the +old Python implementation: the **GRBL Serial** driver runs its entire protocol stack -- handshake, +character-counting flow control, job streaming, stall detection and deadlock recovery -- in Rust +through the raydriver library, under the familiar name. Existing machine configurations keep working +unchanged, and machines saved while the driver still carried its experimental name keep working too. +The network driver also gained a proper **FluidNC** protocol variant. + +## Ruida Improvements + +**ruida-pa** was updated to 0.22, adding **TCP** support for network connections alongside UDP. The +setup wizard gained a **connection mode selector** (Auto, Network, USB) that shows only the fields +that matter for the transport you pick, and there is a new **Z homing mode** to choose between a +real Z home switch and the controller's **auto-focus probe** routine. **@medoix** fixed homing X and +Y individually, and **Sculpfun S30** owners will be glad the focus laser now works while the head is +stationary -- with a new dedicated **Sculpfun S9** profile alongside it. + +## Sketcher Booleans and Path Clean-Up + +The sketcher gained **boolean operations**: Union, Difference, Intersection and Exclude bake the +selected closed regions into proper multi-ring polygons (outer contours counter-clockwise, holes +clockwise), so difference results cut correctly. The Sketch menu has a new Boolean section with +shortcuts, and undo/redo brings the original entities and fills back (#398). + +The Object menu and canvas context menu also gained **path clean-up** tools: **Close Paths**, **Join +Open Paths** with a tolerance in mm, **Delete Duplicates**, and **Break Apart**, each a single undo +step (#516). + +## Dashed Cuts with Perforation + +Not every cut is meant to free a part. A fold line, a tear-off ticket, or a stitch guide is often +better _weakened_ than cut through, and the new **Perforation** post-processor on Contour steps does +exactly that: the laser fires for a **Cut Length**, travels a **Skip Length** with the laser off, +and repeats the rhythm along every contour, restarting with a full cut at the start of each one. It +is off by default, so it stays out of the way until you need it (#525, thanks to @switch87). + +![The Perforation settings on the post-processing page of a Contour step](/screenshots/step-settings-contour-post-perforation.webp) + +## Cleaner Framing, Better Offsets, Sharper Estimates + +- The **Frame** button now opens a popover with an **Include Overscan Margin** switch. Turn it off + and the frame traces only the envelope the laser actually burns, so it no longer overshoots your + artwork by the raster overscan margin (#563). +- A **speed-dependent bidirectional scan offset table** in the Advanced settings interpolates the + raster offset for each step's speed, for lasers whose left-to-right/right-to-left skew grows with + speed. A per-step offset still overrides the table (#307). +- **Crop to Stock** is now offered on Engrave steps, so raster content can be clipped to the stock + boundary (#508). +- The running job **ETA** is derived from the modeled time remaining instead of the recent command + speed, so it no longer collapses during buffered bursts or swings on jobs that mix fast and slow + phases (#496). +- Full-sweep raster passes are limited to each scan line's own content extent (raygeo 1.66.0), so a + shape that narrows in X -- an hourglass, say -- no longer sweeps the widest extent on every row + (#565). + +## Camera and Convenience + +- The camera view can now extend **beyond the work area**, with adjustable margins and border + transparency (#543, thanks to @atkaper). +- **macOS**: adding a camera no longer crashes Rayforge. The app bundle declares its camera usage, + and the first frame waits for the capture session to settle (#510). +- A new **Mouse Gestures** settings page rebinds 2D and 3D canvas and sketch-editor navigation + gestures (#390). +- **Image editing** arrives as an addon: non-destructive edits stored per workpiece, with **Invert + Image** and **Adjust Image** (brightness, contrast, gamma, and an unsharp mask). Addons can + provide their own edits through the new `register_image_edits` hook (#515). +- Step settings gained a **Workpieces** tab that shows exactly which workpieces a step processes, + including the selection that generated content like the Material Test Grid used to hide. +- **AppImage** builds are attached to GitHub releases, and Rayforge is now on **WinGet** + (`winget install rayforge`). + +## Bug Fixes and Polish + +- On-canvas text is drawn through Pango instead of Cairo's toy font API, fixing garbled text and + wrong sizes, especially for non-Latin languages (#507 -- thank you @atkaper) +- Disabling an addon now reliably removes its `action_extension_registry` handlers, even when + several addons registered same-named handlers +- The Engrave step's Bidirectional Scan Offset had no effect since the pipeline moved to raygeo + intents; changing it now also refreshes the cached toolpath (#307) +- Choice rows backed by an enum (like the dithering algorithm) show the step's actual value when the + dialog opens and after undo +- The Ruida setup wizard no longer requires the optional USB device and Magic fields (#501, #504) +- GRBL (Network): a new FluidNC protocol variant replaces the confusing "did not receive a valid + HTTP response" error on ESP3D-style handshakes (#490) +- Zipping a device export no longer fails on 3D models that carry timestamps before 1980 (#500) +- The Sculpfun S30 focus laser uses constant power (M3), so it lights while the head is stationary + (#512) +- Material test: resizing the workpiece no longer leaves the grid's cells and labels misaligned with + the workpiece image until the next rebuild (#568) + +This release was made possible with the support of our Patreon supporters: Build a CubeSat, DaveSys, +froqstar, old-man-and-the-seam, pghpete, Derek McTavish, starlynx.dev, and six anonymous supporters. + +## Download Rayforge 1.13 + +- [Website](https://rayforge.org/) +- [GitHub Releases](https://github.com/barebaric/rayforge/releases) + +## Join the Community + +- [Discord](https://discord.gg/sTHNdTtpQJ) +- [GitHub Discussions](https://github.com/barebaric/rayforge/discussions) +- [Patreon](https://www.patreon.com/c/knipknap) diff --git a/website/docs/features/framing-your-job.md b/website/docs/features/framing-your-job.md index ec0836f9d..46e9919eb 100644 --- a/website/docs/features/framing-your-job.md +++ b/website/docs/features/framing-your-job.md @@ -33,6 +33,11 @@ Trace the job boundary without turning on the laser: 4. **Watch the laser head** trace the boundary rectangle 5. **Verify positioning** and adjust material if needed +The Frame button opens a small popover with an **Include Overscan Margin** switch. The full machine +path includes the raster overscan margin — the zero-power lead-in and lead-out moves that give the +head room to accelerate — so the traced outline can extend past your artwork, mainly in X. Turn the +switch off to trace only the envelope the laser actually burns. + ### Method 2: Low Power Preview Some machines support low-power framing with a visible beam: diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/features/framing-your-job.md b/website/i18n/de/docusaurus-plugin-content-docs/current/features/framing-your-job.md index 540bce05d..534b122d2 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/features/framing-your-job.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/features/framing-your-job.md @@ -28,6 +28,12 @@ Den Job-Grenzrahmen nachzeichnen, ohne den Laser einzuschalten: 4. **Den Laserkopf beobachten**, wie er das Begrenzungsrechteck nachzeichnet 5. **Positionierung verifizieren** und Material bei Bedarf anpassen +Die Rahmen-Taste öffnet ein kleines Popover mit dem Schalter **Overscan-Marge einschließen**. Der +vollständige Maschinenpfad enthält die Raster-Overscan-Marge – die Anlauf- und Auslaufbewegungen mit +null Leistung, die dem Kopf Platz zum Beschleunigen geben –, daher kann der nachgezeichnete Umriss +über dein Design hinausragen, hauptsächlich in X. Schalte den Schalter aus, um nur die Hülle +nachzuzeichnen, die der Laser tatsächlich brennt. + ### Methode 2: Niedrigleistungs-Vorschau Einige Maschinen unterstützen Niederleistungs-Einrahmen mit sichtbarem Strahl: diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/features/framing-your-job.md b/website/i18n/es/docusaurus-plugin-content-docs/current/features/framing-your-job.md index 6cb559778..c0261c818 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/features/framing-your-job.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/features/framing-your-job.md @@ -28,6 +28,12 @@ Trazar el límite del trabajo sin encender el láser: 4. **Observe la cabeza láser** trazar el rectángulo delimitador 5. **Verifique la posición** y ajuste el material si es necesario +El botón Enmarcar abre un pequeño menú emergente con el interruptor **Incluir el margen de +overscan**. La trayectoria completa de la máquina incluye el margen de overscan del ráster —los +movimientos de entrada y salida a potencia cero que dan espacio a la cabeza para acelerar—, por lo +que el contorno trazado puede extenderse más allá de su diseño, principalmente en X. Desactive el +interruptor para trazar solo la envolvente que el láser realmente quema. + ### Método 2: Vista previa con baja potencia Algunas máquinas soportan enmarcado a baja potencia con un haz visible: diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/framing-your-job.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/framing-your-job.md index aecc04add..94f159765 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/framing-your-job.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/framing-your-job.md @@ -28,6 +28,12 @@ Tracer la limite du travail sans allumer le laser : 4. **Observez la tête laser** tracer le rectangle de délimitation 5. **Vérifiez le positionnement** et ajustez le matériau si nécessaire +Le bouton Cadrer ouvre une petite fenêtre contextuelle avec le commutateur **Inclure la marge +d'overscan**. Le trajet complet de la machine inclut la marge d'overscan du raster — les mouvements +d'entrée et de sortie à puissance nulle qui laissent à la tête la place d'accélérer —, de sorte que +le contour tracé peut dépasser votre design, principalement en X. Désactivez le commutateur pour ne +tracer que l'enveloppe que le laser brûle réellement. + ### Méthode 2 : Aperçu à faible puissance Certaines machines prennent en charge le cadrage à faible puissance avec un faisceau visible : diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/framing-your-job.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/framing-your-job.md index 128fff662..e185cc144 100644 --- a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/framing-your-job.md +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/framing-your-job.md @@ -34,6 +34,11 @@ description: 4. सीमा आयत का ट्रेस **लेज़र हेड देखें** 5. **स्थिति सत्यापित करें** और आवश्यक हो तो सामग्री समायोजित करें +Frame बटन एक छोटा पॉपओवर खोलता है जिसमें **ओवरस्कैन मार्जिन शामिल करें** स्विच होता है. पूर्ण मशीन +पथ में रैस्टर ओवरस्कैन मार्जिन शामिल होता है — शून्य शक्ति की लीड-इन और लीड-आउट गतियाँ जो हेड को +त्वरित होने की जगह देती हैं — इसलिए ट्रेस किया गया आउटलाइन आपके आर्टवर्क से आगे तक जा सकता है, +मुख्यतः X में. केवल वही लिफ़ाफ़ा ट्रेस करने के लिए स्विच बंद करें जिसे लेज़र वास्तव में जलाता है. + ### विधि 2: कम शक्ति पूर्वावलोकन कुछ मशीनें दृश्यमान बीम के साथ कम-शक्ति फ़्रेमिंग का समर्थन करती हैं: diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/machine/advanced.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/machine/advanced.md index d9982800e..173d831c6 100644 --- a/website/i18n/hi/docusaurus-plugin-content-docs/current/machine/advanced.md +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/machine/advanced.md @@ -35,11 +35,12 @@ description: ### Z होमिंग -चुनता है कि होम करते समय Z अक्ष अपनी संदर्भ स्थिति कैसे ढूँढता है. यह सेटिंग तभी दिखती है जब मशीन का ड्राइवर एक से -अधिक तरीकों का समर्थन करता है. +चुनता है कि होम करते समय Z अक्ष अपनी संदर्भ स्थिति कैसे ढूँढता है. यह सेटिंग तभी दिखती है जब मशीन का +ड्राइवर एक से अधिक तरीकों का समर्थन करता है. - **होम स्विच**: Z, X और Y अक्षों की तरह, अपने होम स्विच तक जाता है -- **प्रोब**: Z कंट्रोलर के ऑटो-फ़ोकस प्रोब से कार्य सतह ढूँढता है, फिर सेट की गई फ़ोकस दूरी पर जाता है +- **प्रोब**: Z कंट्रोलर के ऑटो-फ़ोकस प्रोब से कार्य सतह ढूँढता है, फिर सेट की गई फ़ोकस दूरी पर जाता + है ## चाप और वक्र सेटिंग्स diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/framing-your-job.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/framing-your-job.md index 01be4e62f..7741df96a 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/framing-your-job.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/framing-your-job.md @@ -28,6 +28,12 @@ Traçar o limite do trabalho sem ligar o laser: 4. **Observe a cabeça do laser** traçar o retângulo delimitador 5. **Verifique o posicionamento** e ajuste o material se necessário +O botão Enquadrar abre um pequeno popover com a chave **Incluir a margem de overscan**. O caminho +completo da máquina inclui a margem de overscan do raster — os movimentos de entrada e saída com +potência zero que dão espaço à cabeça para acelerar —, portanto o contorno traçado pode ultrapassar +o seu design, principalmente em X. Desative a chave para traçar apenas o envelope que o laser +realmente queima. + ### Método 2: Visualização em baixa potência Algumas máquinas suportam enquadramento em baixa potência com um feixe visível: diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/framing-your-job.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/framing-your-job.md index 17be38fbb..ac6cc3bcb 100644 --- a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/framing-your-job.md +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/framing-your-job.md @@ -28,6 +28,12 @@ 4. **Спостерігайте за лазерною головкою**, як вона обводить прямокутник меж 5. **Перевірте позиціонування** та за потреби скоригуйте матеріал +Кнопка Обведення відкриває невелике спливне вікно з перемикачем **Урахувати межі overscan**. Повний +шлях машини включає межу overscan растра — переміщення підведення й відведення з нульовою +потужністю, які дають головці простір для прискорення — тож обведений контур може виходити за межі +вашого дизайну, переважно по X. Вимкніть перемикач, щоб обводити лише контур, який лазер справді +пропалює. + ### Метод 2: Попередній перегляд на малій потужності Деякі машини підтримують обведення на малій потужності з видимим променем: diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/framing-your-job.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/framing-your-job.md index 4d3cce527..04f6e8b2e 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/framing-your-job.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/framing-your-job.md @@ -25,6 +25,8 @@ 4. **观察激光头**描绘边界矩形 5. **验证定位**并根据需要调整材料 +框定按钮会打开一个小弹窗,其中包含**包含过冲边距**开关。完整的机器路径包含栅格过冲边距——即零功率的引入和引出移动,为激光头提供加速空间——因此描绘的轮廓可能会超出您的设计,主要体现在 X方向。关闭该开关即可只描绘激光实际烧灼的轮廓。 + ### 方法二:低功率预览 部分机器支持低功率框定,带有可见光束: From 7d17d3913b8c4c24c75ab2212954ea19b506354f Mon Sep 17 00:00:00 2001 From: Samuel Abels Date: Fri, 16 Oct 2026 03:57:47 +0200 Subject: [PATCH 096/104] fix(website): stop offering the .deb as the AppImage download The latest stable release (1.12.0) ships no AppImage asset, so the asset lookup fell through to the .deb and the AppImage section linked to the wrong file. Match .AppImage only; the section already falls back to the GitHub releases page when no asset is found. --- website/src/components/InstallGuide.js | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/website/src/components/InstallGuide.js b/website/src/components/InstallGuide.js index a41438d15..44d1dc314 100644 --- a/website/src/components/InstallGuide.js +++ b/website/src/components/InstallGuide.js @@ -76,11 +76,10 @@ function findReleaseAsset({ release, os, linuxMethod, windowsMethod, macosMethod assets.find((a) => a?.name?.endsWith('.deb')) ); } - return ( - assets.find((a) => a?.name?.endsWith('.AppImage')) || - assets.find((a) => a?.name?.endsWith('.deb')) || - assets.find((a) => a?.name?.endsWith('.rpm')) - ); + if (linuxMethod === 'appimage') { + return assets.find((a) => a?.name?.endsWith('.AppImage')); + } + return null; } return null; From 3f8590bfad20a19a6638faf884f8e262bbf97d45 Mon Sep 17 00:00:00 2001 From: Samuel Abels Date: Fri, 16 Oct 2026 05:02:26 +0200 Subject: [PATCH 097/104] fix(build): drop invalid Prerelease field from winget manifests The Prerelease key does not exist in any winget manifest schema; winget derives the prerelease marker from the dash in the version string itself. The unknown field made "winget validate" fail the submission job, and warnings-as-errors also broke the step on winget builds that falsely warn about the schema header URL, so the validate step now ignores warnings. --- .github/workflows/build-exe.yml | 5 ++++- .../win/winget/Rayforge.Rayforge.locale.en-US.yaml.in | 1 - scripts/win/winget/render_manifests.sh | 11 +++-------- 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build-exe.yml b/.github/workflows/build-exe.yml index 6362de64a..cf94ff2e9 100644 --- a/.github/workflows/build-exe.yml +++ b/.github/workflows/build-exe.yml @@ -249,7 +249,10 @@ jobs: - name: Validate Manifests shell: pwsh run: | - winget validate --manifest "$env:GITHUB_WORKSPACE\winget-manifests" + # winget exits nonzero on warnings; some winget builds falsely warn + # about the schema header URL, and the winget-pkgs pipeline performs + # the authoritative validation anyway. + winget validate --manifest "$env:GITHUB_WORKSPACE\winget-manifests" --ignore-warnings - name: Determine PR Title id: prtitle diff --git a/scripts/win/winget/Rayforge.Rayforge.locale.en-US.yaml.in b/scripts/win/winget/Rayforge.Rayforge.locale.en-US.yaml.in index 98fb86213..4fd44918f 100644 --- a/scripts/win/winget/Rayforge.Rayforge.locale.en-US.yaml.in +++ b/scripts/win/winget/Rayforge.Rayforge.locale.en-US.yaml.in @@ -22,6 +22,5 @@ Tags: - cnc - gcode ReleaseNotesUrl: @RELEASE_NOTES_URL@ -@PRERELEASE@ ManifestType: defaultLocale ManifestVersion: 1.12.0 diff --git a/scripts/win/winget/render_manifests.sh b/scripts/win/winget/render_manifests.sh index 0dd45bec0..9eb7cff94 100755 --- a/scripts/win/winget/render_manifests.sh +++ b/scripts/win/winget/render_manifests.sh @@ -19,13 +19,9 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" RELEASE_DATE="$(date +%Y-%m-%d)" RELEASE_NOTES_URL="https://github.com/barebaric/rayforge/releases/tag/${VERSION}" -# Versions like "1.13.0-beta1" must be published as winget prereleases, -# otherwise winget would offer them over the matching stable release. -if [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+- ]]; then - PRERELEASE="Prerelease: true" -else - PRERELEASE="" -fi +# Prerelease versions like "1.13.0-beta1" need no dedicated manifest field: +# winget derives the prerelease marker from the dash in the version string, +# which sorts below the matching stable release. mkdir -p "$OUTDIR" @@ -35,7 +31,6 @@ render() { -e "s|@INSTALLER_SHA256@|$INSTALLER_SHA256|g" \ -e "s|@RELEASE_DATE@|$RELEASE_DATE|g" \ -e "s|@RELEASE_NOTES_URL@|$RELEASE_NOTES_URL|g" \ - -e "s|@PRERELEASE@|$PRERELEASE|g" \ "$1" | sed '/^[[:space:]]*$/d' > "$OUTDIR/$2" } From f96819b933af7e2b05bd63ba430b17771fe864a7 Mon Sep 17 00:00:00 2001 From: Samuel Abels Date: Fri, 16 Oct 2026 07:07:13 +0000 Subject: [PATCH 098/104] fix: renaming an item no longer triggers a full re-render Renaming a workpiece, group, step, layer, or stock item fired the item's `updated` signal, which the pipeline treated as a content change: every cached render was invalidated, the intent was rebuilt, and all workpieces were re-rendered. In large projects this burst of background work collided with the UI's buffer handling and crashed GTK with an access violation. A rename is a pure label change, so it now has its own dedicated signal instead of being flagged on top of `updated`: - `DocItem.renamed` fires when the name changes and never bumps the geometry/transform revision counters. - `DocItem.descendant_renamed` bubbles renames up the tree, mirroring the existing `descendant_updated` / `descendant_transform_changed` plumbing. Because renames no longer reach `updated`, the pipeline controller and the canvas elements need no special-casing, and the flag plumbing (`name_only`, `**kwargs`) is gone. `Step.set_name`, `Layer.set_name`, and `StockItem.set_name` no longer re-send `updated`; they relied on the name setter, so previously they still triggered the full rebuild the undo commands use them for. Label-updating UI now listens to the dedicated signal: layer headers, workpiece/group rows, step boxes, the workflow title and step tooltips, the item properties panel, and the stock properties dialog. The document is still marked as modified through the undo history. Closes #571 --- rayforge/core/item.py | 33 ++++++++- rayforge/core/layer.py | 6 +- rayforge/core/step.py | 10 +-- rayforge/core/stock.py | 10 +-- rayforge/ui_gtk/doceditor/group_row.py | 6 +- rayforge/ui_gtk/doceditor/item_properties.py | 2 + rayforge/ui_gtk/doceditor/layer_column.py | 5 ++ rayforge/ui_gtk/doceditor/step_box.py | 5 ++ .../doceditor/stock_properties_dialog.py | 7 ++ rayforge/ui_gtk/doceditor/workflow_row.py | 9 +++ rayforge/ui_gtk/doceditor/workflow_view.py | 7 ++ rayforge/ui_gtk/doceditor/workpiece_row.py | 6 +- tests/core/test_item.py | 4 ++ tests/core/test_layer.py | 2 + tests/core/test_revision_counters.py | 71 ++++++++++++++++--- tests/core/test_step.py | 26 ++++--- tests/pipeline/test_intent_controller.py | 15 ++++ 17 files changed, 187 insertions(+), 37 deletions(-) diff --git a/rayforge/core/item.py b/rayforge/core/item.py index 0805c3286..affcfe7c9 100644 --- a/rayforge/core/item.py +++ b/rayforge/core/item.py @@ -94,6 +94,11 @@ def __init__(self, name: str = ""): self.transform_changed = _RevisionSignal( owner_ref, lambda o: o._bump_transform_revision() ) + # Fired when only the item's name changes. A rename is a pure + # label change: it must not bump revision counters or trigger + # re-rendering, so it gets a dedicated signal instead of riding + # on ``updated``. + self.renamed = Signal() # Bubbled Signals # Fired when a descendant is added anywhere in the subtree. @@ -104,6 +109,8 @@ def __init__(self, name: str = ""): self.descendant_updated = Signal() # Fired when a descendant's `transform_changed` signal is fired. self.descendant_transform_changed = Signal() + # Fired when a descendant's `renamed` signal is fired. + self.descendant_renamed = Signal() self._natural_size: tuple[float, float] = (0.0, 0.0) @@ -148,10 +155,16 @@ def name(self) -> str: @name.setter def name(self, new_name: str): - """Sets the item name and sends an update signal if changed.""" + """ + Sets the item name and sends the ``renamed`` signal if changed. + + A rename is a pure label change. It deliberately does not fire + ``updated`` so receivers keep render caches and pipeline + rebuilds untouched. + """ if self._name != new_name: self._name = new_name - self.updated.send(self) + self.renamed.send(self) def depends_on_asset(self, asset: IAsset) -> bool: """ @@ -784,22 +797,26 @@ def find_descendant_by_uid(self, uid: str) -> DocItem | None: def _connect_child_signals(self, child: DocItem): child.updated.connect(self._on_child_updated) child.transform_changed.connect(self._on_child_transform_changed) + child.renamed.connect(self._on_child_renamed) child.descendant_added.connect(self._on_descendant_added) child.descendant_removed.connect(self._on_descendant_removed) child.descendant_updated.connect(self._on_descendant_updated) child.descendant_transform_changed.connect( self._on_descendant_transform_changed ) + child.descendant_renamed.connect(self._on_descendant_renamed) def _disconnect_child_signals(self, child: DocItem): child.updated.disconnect(self._on_child_updated) child.transform_changed.disconnect(self._on_child_transform_changed) + child.renamed.disconnect(self._on_child_renamed) child.descendant_added.disconnect(self._on_descendant_added) child.descendant_removed.disconnect(self._on_descendant_removed) child.descendant_updated.disconnect(self._on_descendant_updated) child.descendant_transform_changed.disconnect( self._on_descendant_transform_changed ) + child.descendant_renamed.disconnect(self._on_descendant_renamed) def _on_child_updated(self, sender: DocItem): self.descendant_updated.send( @@ -813,6 +830,11 @@ def _on_child_transform_changed( self, origin=sender, parent_of_origin=self, old_matrix=old_matrix ) + def _on_child_renamed(self, sender: DocItem): + self.descendant_renamed.send( + self, origin=sender, parent_of_origin=self + ) + def _on_descendant_added( self, sender: DocItem, *, origin: DocItem, parent_of_origin: DocItem ): @@ -834,6 +856,13 @@ def _on_descendant_updated( self, origin=origin, parent_of_origin=parent_of_origin ) + def _on_descendant_renamed( + self, sender: DocItem, *, origin: DocItem, parent_of_origin: DocItem + ): + self.descendant_renamed.send( + self, origin=origin, parent_of_origin=parent_of_origin + ) + def _on_descendant_transform_changed( self, sender: DocItem, diff --git a/rayforge/core/layer.py b/rayforge/core/layer.py index fbd9a2700..8035c9121 100644 --- a/rayforge/core/layer.py +++ b/rayforge/core/layer.py @@ -242,8 +242,9 @@ def active(self) -> bool: def set_name(self, name: str): """Sets the name of the layer. - Args: - name: The new name for the layer. + Also syncs the workflow name, which is derived from the layer + name. Both fire ``renamed``; neither is a content change, so + ``updated`` stays untouched. """ if self.name == name: return @@ -251,7 +252,6 @@ def set_name(self, name: str): wf = self.workflow if wf: wf.name = _("{name} Workflow").format(name=name) - self.updated.send(self) def set_visible(self, visible: bool): """Sets the visibility of the layer. diff --git a/rayforge/core/step.py b/rayforge/core/step.py index 8c98145e7..0f5bb1e3a 100644 --- a/rayforge/core/step.py +++ b/rayforge/core/step.py @@ -848,10 +848,12 @@ def set_workpiece_uids(self, uids: list[str]): self.updated.send(self) def set_name(self, name: str): - """Sets the step name and notifies listeners of the change.""" - if self.name != name: - self.name = name - self.updated.send(self) + """Sets the step name. + + The ``name`` property sends ``renamed``; a rename is not a + content change, so ``updated`` stays untouched. + """ + self.name = name def set_visible(self, visible: bool): if self.visible != visible: diff --git a/rayforge/core/stock.py b/rayforge/core/stock.py index 594b729ac..71dea7ba1 100644 --- a/rayforge/core/stock.py +++ b/rayforge/core/stock.py @@ -151,10 +151,12 @@ def duplicate(self) -> StockItem: return new_item def set_name(self, name: str): - """Setter method for use with undo commands.""" - if self.name != name: - self.name = name - self.updated.send(self) + """Setter method for use with undo commands. + + The ``name`` property sends ``renamed``; a rename is not a + content change, so ``updated`` stays untouched. + """ + self.name = name # --- Delegated Properties for backward compatibility and convenience --- diff --git a/rayforge/ui_gtk/doceditor/group_row.py b/rayforge/ui_gtk/doceditor/group_row.py index 45a1fbea1..d3f3ee299 100644 --- a/rayforge/ui_gtk/doceditor/group_row.py +++ b/rayforge/ui_gtk/doceditor/group_row.py @@ -48,10 +48,10 @@ def __init__(self, group: Group, on_rename=None): self._update_ui() - group.updated.connect(self._on_group_updated) + group.renamed.connect(self._on_group_renamed) def do_destroy(self): - self.group.updated.disconnect(self._on_group_updated) + self.group.renamed.disconnect(self._on_group_renamed) self._remove_rename_click_controller() def get_drag_content(self) -> Gdk.ContentProvider: @@ -60,7 +60,7 @@ def get_drag_content(self) -> Gdk.ContentProvider: def _update_ui(self): self.name_label.set_text(self.group.name) - def _on_group_updated(self, sender, **kwargs): + def _on_group_renamed(self, sender): self._update_ui() def _on_drag_prepare(self, drag_source, x, y): diff --git a/rayforge/ui_gtk/doceditor/item_properties.py b/rayforge/ui_gtk/doceditor/item_properties.py index 0fb661548..702737c5b 100644 --- a/rayforge/ui_gtk/doceditor/item_properties.py +++ b/rayforge/ui_gtk/doceditor/item_properties.py @@ -132,6 +132,7 @@ def set_items(self, items: list[DocItem] | None): for item in self.items: item.updated.disconnect(self._on_item_data_changed) item.transform_changed.disconnect(self._on_item_data_changed) + item.renamed.disconnect(self._on_item_data_changed) self.items = items or [] @@ -148,6 +149,7 @@ def set_items(self, items: list[DocItem] | None): for item in self.items: item.updated.connect(self._on_item_data_changed) item.transform_changed.connect(self._on_item_data_changed) + item.renamed.connect(self._on_item_data_changed) self._update_ui() diff --git a/rayforge/ui_gtk/doceditor/layer_column.py b/rayforge/ui_gtk/doceditor/layer_column.py index 296d0a197..ccdf4813c 100644 --- a/rayforge/ui_gtk/doceditor/layer_column.py +++ b/rayforge/ui_gtk/doceditor/layer_column.py @@ -418,12 +418,14 @@ def _update_subtitle(self): def _connect_signals(self): self.layer.updated.connect(self._on_layer_updated) + self.layer.renamed.connect(self._on_layer_renamed) self.layer.descendant_added.connect(self._on_layer_structure_changed) self.layer.descendant_removed.connect(self._on_layer_structure_changed) self.doc.active_layer_changed.connect(self._on_active_layer_changed) def do_destroy(self): self.layer.updated.disconnect(self._on_layer_updated) + self.layer.renamed.disconnect(self._on_layer_renamed) self.layer.descendant_added.disconnect( self._on_layer_structure_changed ) @@ -436,6 +438,9 @@ def _on_layer_updated(self, sender, **kwargs): self._update_ui() self._rebuild_workpiece_list() + def _on_layer_renamed(self, sender): + self._update_ui() + def _on_layer_structure_changed(self, sender, **kwargs): self.workflow_row.refresh() self._rebuild_workpiece_list() diff --git a/rayforge/ui_gtk/doceditor/step_box.py b/rayforge/ui_gtk/doceditor/step_box.py index 0c849ed1d..1ee34575f 100644 --- a/rayforge/ui_gtk/doceditor/step_box.py +++ b/rayforge/ui_gtk/doceditor/step_box.py @@ -90,6 +90,7 @@ def __init__( button.connect("clicked", self.on_button_delete_clicked) self.step.updated.connect(self.on_step_changed) + self.step.renamed.connect(self.on_step_renamed) self.step.visibility_changed.connect(self.on_step_changed) get_context().config.changed.connect(self.on_step_changed) self.on_step_changed(self.step) @@ -97,6 +98,7 @@ def __init__( def do_destroy(self): """Overrides GObject.Object.do_destroy to disconnect signals.""" self.step.updated.disconnect(self.on_step_changed) + self.step.renamed.disconnect(self.on_step_renamed) self.step.visibility_changed.disconnect(self.on_step_changed) get_context().config.changed.disconnect(self.on_step_changed) @@ -121,6 +123,9 @@ def on_step_changed(self, sender, **kwargs): self._update_badge_color() self._update_speed_warning() + def on_step_renamed(self, sender): + self.title_label.set_text(self.step.name) + def _update_speed_warning(self): """Warn when the step has configuration issues. diff --git a/rayforge/ui_gtk/doceditor/stock_properties_dialog.py b/rayforge/ui_gtk/doceditor/stock_properties_dialog.py index 02bd6e737..494fb7ba2 100644 --- a/rayforge/ui_gtk/doceditor/stock_properties_dialog.py +++ b/rayforge/ui_gtk/doceditor/stock_properties_dialog.py @@ -36,6 +36,7 @@ def __init__( # Connect to stock item updates to refresh UI self.stock_item.updated.connect(self.on_stock_item_updated) + self.stock_item.renamed.connect(self.on_stock_item_renamed) # Make sure to disconnect when the dialog is destroyed self.connect("destroy", self._on_destroy) @@ -123,6 +124,7 @@ def _on_destroy(self, widget): """Clean up signal connections when dialog is destroyed.""" if hasattr(self, "stock_item") and self.stock_item: self.stock_item.updated.disconnect(self.on_stock_item_updated) + self.stock_item.renamed.disconnect(self.on_stock_item_renamed) def _debounce(self, callback, *args, delay_ms=300): """ @@ -190,6 +192,11 @@ def on_stock_item_updated(self, sender, **kwargs): self.material_row.set_material(self.stock_item.material) self._update_color_display() + def on_stock_item_renamed(self, sender): + """Update the name field when the stock item is renamed.""" + if self.name_row.get_text() != self.stock_item.name: + self.name_row.set_text(self.stock_item.name) + def _apply_thickness_change(self, new_thickness): """Apply the thickness change.""" if new_thickness != self.stock_item.thickness: diff --git a/rayforge/ui_gtk/doceditor/workflow_row.py b/rayforge/ui_gtk/doceditor/workflow_row.py index b781c701b..fb096cf56 100644 --- a/rayforge/ui_gtk/doceditor/workflow_row.py +++ b/rayforge/ui_gtk/doceditor/workflow_row.py @@ -126,6 +126,9 @@ def _connect_workflow(self): self._workflow.descendant_updated.connect( self._on_workflow_changed ) + self._workflow.descendant_renamed.connect( + self._on_workflow_renamed + ) self._workflow.updated.connect(self._on_workflow_changed) def _disconnect_workflow(self): @@ -141,6 +144,9 @@ def _disconnect_workflow(self): self._workflow.descendant_updated.disconnect( self._on_workflow_changed ) + self._workflow.descendant_renamed.disconnect( + self._on_workflow_renamed + ) self._workflow.updated.disconnect(self._on_workflow_changed) except (TypeError, ValueError): pass @@ -155,6 +161,9 @@ def refresh(self): def _on_workflow_changed(self, sender, **kwargs): self._rebuild() + def _on_workflow_renamed(self, sender, **kwargs): + self._rebuild() + def _get_step_icon(self, step) -> str: return step.ICON or _FALLBACK_ICON diff --git a/rayforge/ui_gtk/doceditor/workflow_view.py b/rayforge/ui_gtk/doceditor/workflow_view.py index ee01dd37e..01888f5cc 100644 --- a/rayforge/ui_gtk/doceditor/workflow_view.py +++ b/rayforge/ui_gtk/doceditor/workflow_view.py @@ -50,6 +50,7 @@ def set_workflow(self, workflow: Workflow | None): try: # Disconnect old handlers self.workflow.updated.disconnect(self.on_workflow_changed) + self.workflow.renamed.disconnect(self.on_workflow_renamed) self.workflow.descendant_added.disconnect( self.on_workflow_changed ) @@ -69,12 +70,18 @@ def set_workflow(self, workflow: Workflow | None): # Connect to signals that indicate a change in the workflow's # properties or its list of children. self.workflow.updated.connect(self.on_workflow_changed) + self.workflow.renamed.connect(self.on_workflow_renamed) self.workflow.descendant_added.connect(self.on_workflow_changed) self.workflow.descendant_removed.connect(self.on_workflow_changed) self.workflow.descendant_updated.connect(self.on_workflow_changed) # Trigger initial full population and metadata update self.on_workflow_changed(self.workflow) + def on_workflow_renamed(self, sender): + """A workflow rename only changes the displayed title.""" + if self.workflow: + self.set_title(self.workflow.name) + def on_workflow_changed(self, sender, **kwargs): """ Handles any change to the workflow (structural or property) by diff --git a/rayforge/ui_gtk/doceditor/workpiece_row.py b/rayforge/ui_gtk/doceditor/workpiece_row.py index cde370aa9..d4b9bb570 100644 --- a/rayforge/ui_gtk/doceditor/workpiece_row.py +++ b/rayforge/ui_gtk/doceditor/workpiece_row.py @@ -59,10 +59,10 @@ def __init__(self, workpiece: WorkPiece, on_rename=None): self._update_ui() - workpiece.updated.connect(self._on_workpiece_updated) + workpiece.renamed.connect(self._on_workpiece_renamed) def do_destroy(self): - self.workpiece.updated.disconnect(self._on_workpiece_updated) + self.workpiece.renamed.disconnect(self._on_workpiece_renamed) self._remove_rename_click_controller() def get_drag_content(self) -> Gdk.ContentProvider: @@ -86,7 +86,7 @@ def _get_display_name(self) -> str: def _update_ui(self): self.name_label.set_text(self._get_display_name()) - def _on_workpiece_updated(self, sender, **kwargs): + def _on_workpiece_renamed(self, sender): self._update_ui() def _on_drag_prepare(self, drag_source, x, y): diff --git a/tests/core/test_item.py b/tests/core/test_item.py index abc2ef7dd..82af47b1d 100644 --- a/tests/core/test_item.py +++ b/tests/core/test_item.py @@ -621,10 +621,12 @@ def test_natural_size_behavior(mocker): c1 = mocker.Mock(spec=DocItem) c1.updated = mocker.Mock() c1.transform_changed = mocker.Mock() + c1.renamed = mocker.Mock() c1.descendant_added = mocker.Mock() c1.descendant_removed = mocker.Mock() c1.descendant_updated = mocker.Mock() c1.descendant_transform_changed = mocker.Mock() + c1.descendant_renamed = mocker.Mock() c1.natural_size = (10.0, 10.0) c1.get_local_bbox.return_value = (0.0, 0.0, 10.0, 10.0) c1.get_world_transform.return_value = Matrix.scale(10.0, 10.0) @@ -634,10 +636,12 @@ def test_natural_size_behavior(mocker): c2 = mocker.Mock(spec=DocItem) c2.updated = mocker.Mock() c2.transform_changed = mocker.Mock() + c2.renamed = mocker.Mock() c2.descendant_added = mocker.Mock() c2.descendant_removed = mocker.Mock() c2.descendant_updated = mocker.Mock() c2.descendant_transform_changed = mocker.Mock() + c2.descendant_renamed = mocker.Mock() c2.natural_size = (20.0, 5.0) c2.get_local_bbox.return_value = (0.0, 0.0, 20.0, 5.0) # c2 spans (15,0)-(35,5) in world space diff --git a/tests/core/test_layer.py b/tests/core/test_layer.py index d6aa318e8..e5edde814 100644 --- a/tests/core/test_layer.py +++ b/tests/core/test_layer.py @@ -29,10 +29,12 @@ def mock_workpiece_with_signals(): # DocItem base class expects these signals to exist for connection mock_wp.updated = Signal() mock_wp.transform_changed = Signal() + mock_wp.renamed = Signal() mock_wp.descendant_added = Signal() mock_wp.descendant_removed = Signal() mock_wp.descendant_updated = Signal() mock_wp.descendant_transform_changed = Signal() + mock_wp.descendant_renamed = Signal() mock_wp.parent = None mock_wp.name = "Mock WP" mock_wp.natural_size = (10.0, 10.0) diff --git a/tests/core/test_revision_counters.py b/tests/core/test_revision_counters.py index c1900ba34..7a31b850f 100644 --- a/tests/core/test_revision_counters.py +++ b/tests/core/test_revision_counters.py @@ -60,10 +60,10 @@ def test_transform_revision_does_not_bump_on_updated(): assert item.geometry_revision == 1 -def test_name_setter_bumps_geometry_revision(): +def test_name_setter_does_not_bump_geometry_revision(): item = LeafItem(name="leaf") item.name = "renamed" - assert item.geometry_revision == 1 + assert item.geometry_revision == 0 assert item.transform_revision == 0 @@ -127,11 +127,13 @@ def test_workpiece_revision_bumps(): assert doc.geometry_revision == 0 -def test_step_name_change_bumps_geometry_revision(): - """A real Step's name setter should bump geometry_revision. +def test_step_name_change_does_not_bump_geometry_revision(): + """A real Step's name setter must not bump geometry_revision. - Step extends DocItem so it inherits the revision machinery. We use - a minimal concrete Step subclass to avoid pulling in addon loading. + Renaming is a pure label change; bumping the revision would fold + the rename into pipeline compute tokens and trigger a full + re-render of every workpiece. ``set_name`` is the setter the undo + commands use, so it must behave like the plain ``name`` property. """ from typing import Any @@ -147,11 +149,64 @@ def from_dict(cls, data: dict[str, Any]) -> "_DummyStep": step = _DummyStep(typelabel="dummy", name="step") assert step.geometry_revision == 0 - step.name = "renamed" - assert step.geometry_revision == 1 + step.set_name("renamed") + assert step.name == "renamed" + assert step.geometry_revision == 0 assert step.transform_revision == 0 +def test_layer_name_change_does_not_bump_geometry_revision(): + """A Layer rename (including the derived workflow rename) must not + bump any revision counter.""" + doc = Doc() + layer = doc.active_layer + assert layer.workflow is not None + assert layer.geometry_revision == 0 + layer.set_name("renamed") + assert layer.name == "renamed" + assert layer.geometry_revision == 0 + assert layer.workflow.name == "renamed Workflow" + assert layer.workflow.geometry_revision == 0 + + +def test_name_setter_sends_renamed_signal(): + """Renaming fires ``renamed`` — not ``updated`` — and the rename + bubbles to ancestors as ``descendant_renamed``.""" + from typing import Any + + from rayforge.core.step import Step + + class _DummyStep(Step): + def to_dict(self) -> dict[str, Any]: + return {} + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "_DummyStep": + return cls(typelabel="dummy") + + renamed: list[DocItem] = [] + updated: list[DocItem] = [] + descendant_renamed: list[DocItem] = [] + + parent = Group(name="parent") + step = _DummyStep(typelabel="dummy", name="step") + parent.add_child(step) + step.renamed.connect(lambda s: renamed.append(s), weak=False) + step.updated.connect(lambda s: updated.append(s), weak=False) + parent.descendant_renamed.connect( + lambda s, *, origin, parent_of_origin: descendant_renamed.append( + origin + ), + weak=False, + ) + + step.name = "renamed" + assert step.name == "renamed" + assert renamed == [step] + assert updated == [] + assert descendant_renamed == [step] + + def test_signal_is_still_a_blinker_signal(): """Ensure _RevisionSignal subclasses blinker.Signal so isinstance checks elsewhere in the codebase keep working.""" diff --git a/tests/core/test_step.py b/tests/core/test_step.py index e5f1d2de5..0eb378e9a 100644 --- a/tests/core/test_step.py +++ b/tests/core/test_step.py @@ -80,30 +80,36 @@ def test_set_visible_fires_signals(step): visibility_handler.assert_called_once_with(step) -def test_set_name_fires_updated(step): - """Tests that set_name fires the 'updated' signal (issue #343).""" - handler = MagicMock() - step.updated.connect(handler) +def test_set_name_fires_renamed(step): + """Tests that set_name fires the 'renamed' signal (issue #343).""" + renamed_handler = MagicMock() + updated_handler = MagicMock() + step.renamed.connect(renamed_handler) + step.updated.connect(updated_handler) step.set_name("Renamed Step") assert step.name == "Renamed Step" - handler.assert_called_with(step) + renamed_handler.assert_called_with(step) + updated_handler.assert_not_called() def test_set_name_no_signal_when_unchanged(step): """set_name must not fire when the name is identical.""" - handler = MagicMock() - step.updated.connect(handler) + renamed_handler = MagicMock() + updated_handler = MagicMock() + step.renamed.connect(renamed_handler) + step.updated.connect(updated_handler) step.set_name(step.name) - handler.assert_not_called() + renamed_handler.assert_not_called() + updated_handler.assert_not_called() def test_set_name_propagates_to_workflow(step_in_doc): - """Renaming a step bubbles 'descendant_updated' to the workflow.""" + """Renaming a step bubbles 'descendant_renamed' to the workflow.""" _doc, _layer, workflow, step = step_in_doc handler = MagicMock() - workflow.descendant_updated.connect(handler) + workflow.descendant_renamed.connect(handler) step.set_name("New Workflow Name") assert step.name == "New Workflow Name" diff --git a/tests/pipeline/test_intent_controller.py b/tests/pipeline/test_intent_controller.py index b67699601..2be925c86 100644 --- a/tests/pipeline/test_intent_controller.py +++ b/tests/pipeline/test_intent_controller.py @@ -210,6 +210,21 @@ def test_signal_triggers_debounced_rebuild(isolated_machine): ctrl.shutdown() +def test_rename_does_not_rebuild(isolated_machine): + """A rename fires ``renamed``, which the controller never + subscribes to, so no rebuild may be scheduled.""" + step = _TestStep(name="s1") + wp = WorkPiece(name="wp") + doc = _make_doc(step, wp) + tm = FakeTaskManager() + ctrl = IntentController(doc, tm, machine=isolated_machine) + ctrl.connect() + + wp.name = "renamed" + assert tm.delayed == [] + ctrl.shutdown() + + def test_second_change_reschedules_debounce(isolated_machine): step = _TestStep(name="s1") wp = WorkPiece(name="wp") From 46d731bab01033aa1fedb695ba57b10abb4c203f Mon Sep 17 00:00:00 2001 From: Samuel Abels Date: Fri, 16 Oct 2026 17:56:35 +0200 Subject: [PATCH 099/104] docs(sketcher): screenshot the polygon tool's result Add the addons:sketcher:tool:polygon screenshotter target showing a hexagon and a star as the polygon tool creates them, and embed the image in the Polygon pages. --- scripts/screenshot/cli.py | 1 + scripts/screenshot/sketcher_tools.py | 49 ++++++++++++++++-- website/docs/features/sketcher/polygon.md | 2 + .../current/features/sketcher/polygon.md | 2 + .../addons-sketcher-tool-polygon.webp | Bin 0 -> 21772 bytes 5 files changed, 49 insertions(+), 5 deletions(-) create mode 100644 website/static/screenshots/addons-sketcher-tool-polygon.webp diff --git a/scripts/screenshot/cli.py b/scripts/screenshot/cli.py index 97486293e..77cf3f920 100755 --- a/scripts/screenshot/cli.py +++ b/scripts/screenshot/cli.py @@ -117,6 +117,7 @@ "addons:sketcher:tool:path-pie-menu": "sketcher_tools", "addons:sketcher:tool:arc-ellipse": "sketcher_tools", "addons:sketcher:tool:rectangle": "sketcher_tools", + "addons:sketcher:tool:polygon": "sketcher_tools", "addons:sketcher:tool:chamfer-fillet": "sketcher_tools", "addons:sketcher:tool:fill": "sketcher_tools", "addons:sketcher:tool:grid": "sketcher_tools", diff --git a/scripts/screenshot/sketcher_tools.py b/scripts/screenshot/sketcher_tools.py index a269d72a6..4ae3c3096 100644 --- a/scripts/screenshot/sketcher_tools.py +++ b/scripts/screenshot/sketcher_tools.py @@ -1,12 +1,13 @@ """ Screenshot: Sketcher drawing and modification tools — path, arc and -ellipse, rectangle, chamfer and fillet, fill, construction grid, and -text boxes (including the font properties panel). +ellipse, rectangle, polygon, chamfer and fillet, fill, construction +grid, and text boxes (including the font properties panel). Usage: pixi run screenshot addons:sketcher:tool:path pixi run screenshot addons:sketcher:tool:arc-ellipse pixi run screenshot addons:sketcher:tool:rectangle + pixi run screenshot addons:sketcher:tool:polygon pixi run screenshot addons:sketcher:tool:chamfer-fillet pixi run screenshot addons:sketcher:tool:fill pixi run screenshot addons:sketcher:tool:text-box @@ -29,6 +30,7 @@ FilletCommand, GridCommand, ModifyTextPropertyCommand, + PolygonCommand, RectangleCommand, RemoveItemsCommand, RoundedRectCommand, @@ -201,9 +203,12 @@ def _capture_geometry( return True -def _preview_start(command_cls, registry, x, y): - preview = command_cls.start_preview(registry, x, y) - state = (preview.start_id, preview.start_temp) +def _preview_start(command_cls, registry, x, y, **kwargs): + preview = command_cls.start_preview(registry, x, y, **kwargs) + if hasattr(preview, "center_id"): + state = (preview.center_id, preview.center_temp) + else: + state = (preview.start_id, preview.start_temp) command_cls.cleanup_preview(registry, preview) return state @@ -383,6 +388,38 @@ def _stage_rectangle_scene(element): element.set_tool("select") +def _stage_polygon_scene(element): + """A hexagon and a five-point star, the polygon tool's results.""" + registry = element.sketch.registry + center_id, center_temp = _preview_start( + PolygonCommand, registry, -75.0, 0.0 + ) + element.execute_command( + PolygonCommand( + element.sketch, + center_id, + (-40.0, 0.0), + is_center_temp=center_temp, + ) + ) + center_id, center_temp = _preview_start( + PolygonCommand, registry, 75.0, 0.0, sides=5, star_ratio=0.5 + ) + element.execute_command( + PolygonCommand( + element.sketch, + center_id, + (110.0, 0.0), + sides=5, + star_ratio=0.5, + is_center_temp=center_temp, + ) + ) + element.mark_dirty() + element.selection.clear() + element.set_tool("select") + + def _find_corner(element, x, y): """Finds the junction point nearest to (x, y) together with the two lines meeting there. Returns (corner_pid, line1_id, line2_id).""" @@ -631,6 +668,8 @@ def _stage_target_scene(target: str): _stage_arc_ellipse_scene(element) elif target.endswith(":rectangle"): _stage_rectangle_scene(element) + elif target.endswith(":polygon"): + _stage_polygon_scene(element) elif target.endswith(":chamfer-fillet"): _stage_chamfer_fillet_scene(element) elif target.endswith(":fill"): diff --git a/website/docs/features/sketcher/polygon.md b/website/docs/features/sketcher/polygon.md index af21bb847..5658b9a25 100644 --- a/website/docs/features/sketcher/polygon.md +++ b/website/docs/features/sketcher/polygon.md @@ -8,6 +8,8 @@ description: The **polygon** tool (`G+X`) draws a regular polygon, such as a hexagon for a coaster, or a star. +![A hexagon and a five-point star](/screenshots/addons-sketcher-tool-polygon.webp) + ## Drawing a Polygon Click where the center should be, move the pointer and click again, or press at the center, drag and diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md index b0246dae8..cee29731a 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md @@ -9,6 +9,8 @@ description: L'outil **polygone** (`G+X`) dessine un polygone régulier, par exemple un hexagone pour un sous-verre, ou une étoile. +![Un hexagone et une étoile à cinq branches](/screenshots/addons-sketcher-tool-polygon.webp) + ## Dessiner un polygone Cliquez à l'endroit du centre, déplacez le pointeur et cliquez à nouveau, ou appuyez au centre, diff --git a/website/static/screenshots/addons-sketcher-tool-polygon.webp b/website/static/screenshots/addons-sketcher-tool-polygon.webp new file mode 100644 index 0000000000000000000000000000000000000000..2d8e6aad20784b08ee22ecda5f69224677664bcd GIT binary patch literal 21772 zcmZ6SV{jz>*M=wBXk**<#lI?iMC5=rBOI}WX5gTq0^a<3U>%EjQ&ksBXd;GxHp;A;xr-F{>w7sHe>Ws9>2kQzc07n?HS0n!dY-KfQe@Z~k<+5i3Iza|kF3 zfKcN-}G;ryF1=6k+LN74=^jx4G6oUu;coc{Ishb~CIsT{gv8mq*k7 zM3ST~()cq@?)!?3a$C#SA{qxG1to|#3w8hq;UXqj;lwQqo!WUNEFszJa}-h#=MYQ< ze$L2+>&uNc+d@AJg%%IwGW_?ZAdXwbq=H2AVXh$lVLuJyJYe$$se$VE1^b|%^9}lm zanddD4e@r3KB2Omhe-~hqK{|aGGLd4+6}dUrh`{_jZ6(wdLSwRK8;XX@UkhyD@0A@ ztwjl25WmO&2(6;@vG%SadF!JCn}qsZzuCnl^qCwrQSD{{IVLnDP1FCYvpsbSA5uzkdV9N=0Cgjl{pV zF6u6(yS7G-r&Tj9HeYPNY%v48SemZs>S4Y*3J)i-*B!c}Go`$+5wz(l;`lXbF4SR5=3M0<&gE*(+o6;JhC91S~0JId9Ua0xDAP>c21k zW`77r)@NRzp*;z!%Bk+4!Ef9^)rEj&AT`#SXP4X2kc~VgB5-N+OVjOC79^!bo=HOy) z*L|suIgTnixzzP9v>Wq9H(qQIr@t(7MP`!3Wi05x7R<`R2KX4AHSJ_f>7zAlf-2EkLt0z1f4x<5dzzQXq5_)iUhiKWhqy)+ z6>G-vP5abJ;ggB$cc#`0tQH1_{Btalhl$0f95{uxs_8yp<`08XmXgy}L;7RP?49$Z zmi_Y)NM7ot9%#D3!5XNJ4~>td0fjR@pf&g>6FtnJ)2KQaxIJF9&qLfX_$aNV#Un-# zI*-w0fOZYLX)-DEOo;K_4AyN>y1w6%4K6>Oi@0wC;#)rpL5+H^S|hzGU{)+r+{~sn z9$GNt3=7%P6Tz@9&VE^xNl+abK-8^lsYLe^E%e4i!D6kGsyFtEOGGm16HOY$A1>nO zAFQ$hrVzOxb_RQ8?Av$MoH7T%v{*nlf^1R*XDW`BC?r#t9 zT`_4FNp<=?YNmde4%_x`cFEVV+bJ$%G5C*-c{`x=#ZMy-^Eruh$TLW%0@IT0T-Xp- z*o(UOPcxS)F)9iDAArId|auR!z`` z-s1&dmS@ETl!P$k(8MWB22sNbuQ(;r!90@RRHRA!ku0Bd)sboRh9Y$=osvA3ru)Q& zS!(7EAB5T%%xZbD9EWHJ(7ZHIcD&4gNGtRSO=^eB8g$i>NU-&Yl{ z+?bAN?mCYWKqR33ff%kXU-99i^g%I8io1{$wNWnTML6G z?9!^rsQ{_~nP2N$L73;PDY$G(BI=ozbSD26eWrV?PWf=7k_>w!5?@24vZRdU7vBQ2 zkN~LYu6E17%LdQjB+VHY^~lSGnEw68yEBE7jN!8_$3P|R1nbY@3snEV!k))UBG0E< zws)b+4YXenTOM|0pfV}zcOF!l$!+RHl$x(Ga6Y%54M>x(Y5MXKJ>tKX)kdstQ5-z% zgdh-S*QZ*0+;@rr`!ouX$*T2~PxBsPd25{U5AJygYELh9sB1BRtNfnpCb$}dGN$SP zj}Rj+4CXeN`-vilg^s3Dp(J0m`9?5YVJ5HkI@VzV)qJs55^1IKZ4gZA?a8<=b~)y^ zRi1l3P&Vv8)^1}ZR7cry7?NByWy8VUo_u-rfw}hHnA7GU_9TzsCm!P!hkSE;2PFrs ztV3tIF1EfnwQ40Jg^jcNz@+tiiEnH$l%xF#Q@}h~^vNCX9!be&+;+`N>tU~^_8q*E zW@j*B5!39lpcRy*?Q~sJlQq5bN*iAxCZk>-Q*=Vxj>F^_WE0XJ?}O&p-Ud_lCrG&$jDP|IDprGc0NGf zS5h7higlR9v{vb%v=gfqN&vT`K}VUv`4+ zX=nBcp;p91rx?L#EsJS#CQNwUMFrL(PohBWTbSpKr@RIJ%x15EwN$BEd@JAOpq z^Hr-5B-6{AqM5Oy62^yRm#jdk&$a^BpQh`0o@dX5v29rt-NN(_S9E>Jx zFkr6m6KkNn6FSX7T129s5lo`A>_g4-ie|vE7vYu=P{S7yU6IDT(?l&>(E zqSKNlU7E4kP-bZ_#h+_TbEqEdRJQ*O90JB(7C;9IlSB!c2WozlIc8 z1j^s~1Ix0p5M{|*@k+W%7EP4iN@SR)q;_;m4X$?{ za%byq(kuh`fJ_Tx(26N@!J~FET5;fXL*QObOzK32o{|zJZD9cl6kQ>5In-mb;3yr$ zDdV3nFXS$b=;C+}poZooPOz9+EaVV&xJVF7?iPP&@;gJEEAG6h?PK{m_v*u{n~uN5 zrAEwh?F5@E6YmgOb}l(-b!vUmDJp*Y5+CMZz)6^-Q{Slz^={QIc#Y!;|88?2P|v`A zeiDiWc&jpEteE!*3G3gcfGXU)q4$5kq0=bY&Yaq}TSHgic(?6P^$H2^_tOK|D9tcn zUyp=7GhSwIUTy;b{x5!h_2*KJY(L@QBhCl{Yy1&AVgCIQa9HMIr%{VsC9~LzcbHYw zyIMPWhWuIGA7SrJ*h!2@o{0e1QW+fG0!H-llwUOdp6ud3ufbqJ00~p-xEm~1qgA_8 zN7v(OI?VmNI0%TLylRsJZI6mCUo+j@#Ukt1~vvK!FySy?~CD6-QZJN~39Sy;kq zS{?^;6%tg)rldKA&%L5Us}-3}Q_W&czg=$ly1YjqXoMQkU7D3LtTiI@hSn^oPNtlYT>7!n~bcpwo3rh}FG`a6lJMAjT32V1~+1 zNklOi4lK{25CD#PTNkFLoi=qyop(cgUTxW_sg^ji+GmDt1k}XZjxy;?d5|lN&J69@ zgP2x%$DjywZ5_RrWhSk3{O=hqVg3iasE&)=S^|SbEs~hL@&IHVPyveiMC-B#q*vB? zr}3~b;Sx&_6z-SCz7v7{+0Ce5*S_aP9i5uMnxus|=hrXmjSK92>wPr5{FL$Y_EVj9 zJ>O^;0GxXkLftP5>fU(216yPdlk^6tvKz~XmlsF5f{Aa!qd<0${9`bIa7WNnPG9tG zj@P0f|K&PKP7o~Z9hSFVfi`QuZ5|b|rx(wMHUr%r5CH5;_(xi4hJz3WKjOL{2KA;6 zB6ffrxa1q)bPx<(I3rG>Vqr+?>=tov_aJ?YO7qz>oPy~i7=#O=L2$qP$ICDm=|d#! zW12j2>pz{vA3miwyPPT>$HSYHk$M~os%coKsvyb2wBg&TZfjNPP6c*jYt6^mhK>e+ zP8k3;$T}{r%YIG3-4kGC5Hu{piAhW2*07?n?D6L&SEq!6#jJ~Pck55zmN>3pJ6sA+ zS&Hoe2CGer&3;xedaRrTs4pyr_?n=WGyCN3xea)Wr;5oFGSE3`7h%RVMR-6BPp`zh zGt;w9AJLreaX82uUoKu-(3P#-3LAtc22gV_EhMnA(_v-gA3-c!c*)8T^rOR%Rw3u` z%aK!|Us_dS8txw8I4pwJ4pZN}_&>ZEtS=*K1mkxz>90kP%a73#cQPbk7G|xB5orU3%2n0U*_T$ln@K>!b%!7I>AvYhF{T+3}OKxm~rcunRB)sK^_4ry6IHN!1YjnLwi zt6RTScR+Vc5FSg0rS)r#)nXaXsgu#YRREWRb{Anz8uY-w#?w(yZN-WRp%%qBOemx9 zlp}~VBu3DOu|af^2I8a@)ZgO;xTr*<8f#sf>Ib~_SV`O`*AmqL4LI~t*=7oyfd`HI z!%FZlVP$pRsUyGG?({7-mLC=f@a1f{-~pkn9LC;4PfB&SU;%BH_gsLs7&ki&lXs`c zvN{j}Y+~t?OPQC?1uV#r$!zR7+!jjTXc}VIig1k6&}G+ql)9AoQ*c$sV0|2cYFxX( z-{w3F)-((h+Y8a82UR~bjCb`fyP zqf@@H6yiYy5&v(ITXb97RFvtGjF+4Jc}rh1|AW_C`>UJ|!@J2K%`YitA_=aVpwQQt zQ+_?wgdUUvlxwjUqVac_RpxZ;bdJ@b{Cm8Jc?Ms(QJwU`p|li~plTbXi6?@8daxCi z90!clc@}sw&bi* zBsj}0N?k}Aw)Q|F*uz$?DtQN$Mq@Ggio8nULl{~TxqIGwYHBNM3Jp%U9xmT10H+8W zsWW?{pO00=TALx}E1HUEB>!uthJ+n`M)lZzs2P&uwmoi|0((w7dCb_+5N-P+b<15z zqJGC;piSIi@$ZA1piyrXO>u9SE?h(H_KC+kjw-Dhv;8A2Mcc)u!cY^uthy{*h>pGO zL(V}Xf5-exmyi?>OT?$tcZU-Spke&7OPj0sUyUesT;5jmG@I1NOHLAwRZG6#Z>?>1|?CwgmHDIL*!d=OOkXHEHF;S|i$>SMdVjJK_XC*ZgMn zq|%4Db`ogC*o7HxkRO6m0v)EIa46^9`_u%Nrb8CP1p*6Y zfTIsdv3l_k2IP!MBT;#Akuud?Y)jml^r-uIye{oY!w(y+KKYTo$<(i}2L}HiNZZ=m zD5OK=hp$Ke%7{6CU14`n4Lpl~fFbVI`zRz~>;Du;;h{fx98g@;qP}ZD(Bpm5D4cgt z#UcIB@Pchv4Y0)Tkbk38g?>P}bkY0WsX)1n)q{U$Hr=$rv`}&e5#SUlgm@5bD>E-Z z`WO}uU*WCvOl*K0y|snWFwIn4$cN~+_Spp@({BoypJ?MycCGU?)vd78(KDwYo>c1Y z2k7L-K&)FQp@T+BT*_LlXwGm9WkGaAbdTzR!G>y{saE++ zv+DJV>JG?Ub_g0cA&~9FEdHECuv#G#jgV`J6N4>LP$wNT;tzAhd;av3Op&ZneV{B8 zb^(e-BrltDNIR36+=(R+X($KxR_Oa9@EhxBSE3z0n@k78wQ)#tF3HQqKduB+583c~ z4bFouL2i$t>W(2ffqH^(?=&vOIanrC=y-44{GR_xEC>`ycAHMO>AL>XC3opcx`|i9 z86R|h^7%?mgY)p-M7V=B^FcGF-<}aZ#5Va{yJ3LwY<*;esIB)(g>a5uL><=cfv_84 z2G>4GaQ~Qs&v7Qe4|j8F0~=f!^iPSWEB>Q(UjCv51Foz?#QwoE2LsUsi(lgB(GX64I=e9n)f!x{B| zRU>5e?d#axpM|Zj79Z`uGNZh|tfI`iI~mj?zlxkc1u2bE7SFDfYC{T6lk0iZ5~`$t z5m=f-x%-#YweDN`}Id3w{AiCRO6)82fGpEO6O)T4g^5P3G5T+ zxWTn+_BmPek%RPeEUS~p>1pR+8A`-HtLbj9mF=^n=UDJC`FR4Hk;25a{B=kmpW-ZR|8(u{r!7K*qvLhR63?)sllF_|2c(S= zT+p&`_A>&87aiwq3phpH=ZNMO?v`|e0p?I$`rF;B`e5d1Da|EbrAR`Utb~u ze7Y~?Wxnu1sV@ z6yO}@htwr(v`l}#+%C^SR*eyz!(PMVi`-1U)I*9-viq!I!nBC%b)(DU9l?R7v*H77 z`a1ayEFR^0a@l1AzPSF?Mm!?v`F1rwhY}5@*UfkMDfBBq#p2>@ne^8Lh{Owts=pT|s`zII$oa}RA!qWqJT4+MC zdON(Dlu1o)REs4#WQZLiLQ7UMI(JKcgbenQtMxCga;Q}oP1{)$i~1)l;Iv5{(TmRU z>+9tH>8&aQwbHKq!P=1$v-A2N?@8G$|JD7c=(%su-98u?EWK&3TO>hc?5bfI;xM1u zrPkeiah`nTiLis}=Y;?lC3(!^!y##wN@3@nfPNA_a$a2V+(;1#i!6SFpBwXD_x=P- zdT#J3!*5e$istou`*SI1En~s2wY^0 zH?wBH{igedBT&rcpm#nUi+kq8y3$E%I1I&H=ebsSls2Y{3@FGmKX=XBZK%FGXnpzk zSg?xy*?yt!7m;}dk{*WK$rq-BqgBZsqN!%!Z!g-RYEV1`P3V23fHGd!>HfSHjt1&j zE$Q3mIiGa9z$C?al8Eey*XKFyZ{x<)1q+pS+g5_`o&qP_i&;WJgHX0}2qj>Lf>-L+ z@F(W*3IZSl!YOzM%Xibio6)zJT`E%#Zq=|hZr+7@pe<9d8l$LT7bqu~-in~d=N_cW z9Lo@dtTPa4HgSl2)Kw;vDxU@Qm3mRr+j*F&+v8Cf%OS8EukWaAHN(=<@-<__z; z|CVMta5}ig662RS@%8d7sQhY1C1*4Gx9DaqS;4FWf0apx1!nb63+0#Cylwp$_5FYV zp;~daqJc};n)m_tiPa)(^SjOIr$D}>HOBc)*pJjevVSB=`xkxu&X3i5^+^19Ug^2KXrA7 zIReiw}WduUm{)5NQbZGeR4K%i6+hKaa%*JKfVukeMRk@hdQZOmDm(7dsM33t$nj28F$Hqw9wPTLP5P9&->^R+sc6fg z%dJ+ojWb5a)g@IvN2gwbDzB>4NIRo5p{RrXlR(s7`MBzbm2!}c`Ej9BtGVRu?a)k^ zYyKwf=?sh}R(9cOTLI3|b507(2(|3+DaAotcSc*_VbjNi?D+vp@LDn-L+=HG{AT%Y zVy_xdlP)&QPCPUATpNnGO!zGa`Z>_a;T&G!Ut9_=LdxYf{?EVId3W(!8(OnHp|#Yj zZzi+GyS@!-an|88UO#*F+?Sz6h4m#Z=n7$tu&3gua>?*k(vTAwhP&Ws6}|;ezUi}_ ztl8JcG^jJ`d!4M8XYegxq?7n+iBEJ~r`uzVq~ACi=k8DDwp=F9U5_{7kC$l6N@8`- zQQzZ+_(?D*Lr9%iZUSe#qkNFu;d9IeXM4AU%tF;G?iJ8qK~d&;$A?xMc`Yt#rIO}r zUJie;>7>ItaJSysb_U)cdGXq5xG$7*bT#GxyT4(LLNH^kGaL=VCG!-Q4h!cYDEHs< zXEP4TP7MpP=NauFoJ`KJYs4Iuv0uavUBY=%Y*cMJtBm49&F|&ExgVnbv8_k$o(RIy zP@eXT$yIT`_tTv;R2S73a1V52NY|QC4Cx^)WhZw*{DqgSl};>60(L9}AwwnS?OoRJ zG6U~pC#s)*P^~hR*IBIbrp$_}J_|O3$(l+Q4-UgHm<5L+!AoWr)Z+G&VY`P|s5%zl z6LA};eEG~LaBg8y}cMHk>n|d213ssRLyCtC4MioFbwH@7HfFnql+ zMlYza2qd6P)uq*_x;=Yvp?&0`m2JkgP-Q-`g>XTl=3sULb^&3r{V1P8d~nF;29E;L zLOcrzZthOrNq;q!_Nm8*CmYt_elc-fHi?9F8N|EtK`lxdW70?nS=~2sk?Rf}5st!lYzN=h6g zb(-gB5h>*EnFi}AkX83FGM5#+FvI7^?{)87I3zJ>cSjzYt7@uMP|5xF)^j*{oOcAF zqxD)-*p`(__I9@2T~@ey95>9|I+@~3MW1T6Zl_o9rCVvTj(NQfXxHvoRolh9B570= z=XpeEs!*Av3rGHad2|+9{k<@mZW{TY%h9q(s*^Z+@&U1PhtbWmz*`WFVbH=`deG>3 zmLwX^#MwzKP=IS=!r{}dRJFGZWF)HlA{k2lJHbex0gqbe)s9}DrcoqesadP$Z82rK zhA3hSegaZ;QC?HLn@D4yycHF7nXz8~WpsaJF9;F<`nofzyM-4jg%hKtF*gy>?5@Ev z9X3r~DYMEejnzr=DPgwtQQGb8=qz%%OGlHmxFLCh^+~4L@>w~wo}Z05lfZrQGI=^n zhzSf29(iQ-_QrslOgTv|AA4;U{|amMX_3g(i7(1Yb%E7a``YP2NSDm7P58)WsAISUj+ zWD(Og7#$1er>8kxr5N_zx3pe*K6iSOd~I6f!$A(_1#Vr@Nmo8hy!tv3s5(O)p_)IC z%&8yfMyeqjp7J!n{5|V6SS)ZE>_pOrJqr#xwqx^w1Wbgu)$ULn1G8WN-BXie)p)v> z{zuVo7K=M^^|ImH{(*BI^xJ|$|;UeH&-jQR6bUsSI_F;@Y88#-t&~%JqN2D;Ux6zytAj zS>A5bQIdeUID27JvT3r1qK$tPETayz2Py<8MediGy>cznJv`r3q0bv+!~1m8c0Coe zx1qmDOQ7Zu>iy*eeF!(xXDBoJ_aedfZgn>tp+{UH-B2E$oAm&Mh1mTAH~s(k-Dp?W z-^~gs?$m@9!7aGYS0eG@+zW3P`@xUZPnJB%sN(HJ-b@dU6II_$9v}u|Y_K-p8ot;r zMfh6yrJ6l-{w9qd-Kk(d4j^42ddA3a<2u3W#csK4cSX9uNpsa8N3(Te&K?>E9VaWO z#-}G!mYY;gyBe9d!PgSyWbw9;7Ovyz%Vn*qP*o~t1H+-%e1`W4rVh2hw@+6A80`3O z^@PJ4+3brqz}u9_`L7YxN9}A0f;b-v#0W@&`*h{D7~XNyI95E$IkImyUR!|sk@s?eI*PXr zeSzhj<$V4|>K1z?bzZrDCSOCu)(k8+$&PrxgV0S^&zsRT4ENZSysPq6cWGo9o3Qy)<986Ek1Xu$o{tTW$qfy5d7>^exx>d z=!Rq%8M?6oSv8P<*b3j1(h%}yc2cNfHFIxY5f~MSI(OQ z+pD?R_VYpgeoczcxohuOg zL5a$Lf##F};b6f_IF^G=HLy(DXsja@hNTVHy>eESQg1#8xYEAb>F_JTVGlY^`$nwi z58kr((hcNKU2eD*!V4Wb>BdOB**~dtJy5GWppj(OEPwGEaai*74I;=mP@{Vzbez#9;FN0|BIGTHh3jP}dVeKer|Xq%#WE(qupS+HZ!G{V7+ z-1e#$I@x+yQ&m2HRl&lw<-L9=4hQM=<(@J-wyiFzD6f1V#oWukB)S8dWeK(`OldHA znwCu}(R95uk8J;i*Z$`Bd7ef*CBboe0)thhwP|cmK-ECtWux<)*|;*455sj9 zE*BNyI4GqkXx>I|GG_BnhQJe3s{7VWp7xFP{kv=Fyz^vds}?KZY5eOWeOb*sZ9XSxGQFLGDv?bA>Mu4FPiV`49kesRzPHJlLa>Etw~5-%7#W_>Nnlq$wlX_cWg6@+!zA^EvyP!E(~c^q`OkBE z$I-@ip0VxBtsGjmLFnxn$RX*XG|XOteO_Qf-L3M!h?CV$nYC9wcOFU_U6P=;HT^Z> z&)m96O{jnDwoj`qE$xcp{{VL{J3sWH-+eo}-{LIpCaLaf15#4$RUeHD6u{8#7ER}@ z{gO%wMSz{vrk;zstWA=hamM^3Ep^w$;joW0%X_iU=UE`IHYE*a<wD)9JnvJdL46%+AMc!J_d3FqZJW^g2ZjDi zw^-GEOPOw;9*T9b3PvgzHVbaAbx269~`p%~#; zKx8W!Gr`*+-Po%cu3EMv6}87CKZcUQZ zsUb2A^w0N>wUWE8^fOzn&&*|?9h9%LK$v^9P5PpCr#N8vjbnY*j9`QV%Xj$bK!6dC z77PjqV2H*TI!6ysS1VVm zMEQ~=u?&HB(EefzhYI&5>cKxlAHo9tyailPaB03GXyU!>Pe6F?%mW~#Hv1D8#NEY2 zfh;MXkUui>vd77I`salwP&z(+MKN{v@NV%_z`?}8?VulPD5g&$dJzbRMb|OQU^TrL(99QdW9+fNs^D8Rh_AamK5qu^a9SB>!7uG!)?e1#U)>27oQ{2;@IS0r6OTK=R-U`) z#t?%Iq9M`J&50YV?%m3#kT7=(QGd=XbVhf~?8Jj|>RPjNS~isME^v z7$m!IwWH8xs18^VeukM}l>6(SOEgpb!gS`hYOFmk&e$W<8O25JJ)f`~(oh_l4aV>w z0G*>wbwO@q`o|a$7xoZhppO4!uG7s^5o(*5&2jJ1C){CRikhG(DTLqfB>z3Uym%E zjw*iyGjih0_~D~i-y6hiI_FZdvc+S2PV0g_kZOU4CM#I*M(kwjq~Hh-w)MXCPiHSq zNk?GldiAOOn#}xUb$;bNUwP$^H@~mt_8uTGq)Rt0l`*zxv`Y4SV*^vLx_|8xVM8dK zY%0K$=XS=dkO3x*pzJ4^jN-(uMu8dug);bRm;^buRog2LoQ$&X}t-l z>-jr|U_r_Y{q3L3x_<)FW4`ncHVM23T73~bzr(K)g?q(O?XqtzLmhG7DwLUoIJQmi9i9b%Ad-fPC zmDoDq!R~Fra>B=x;MVLG(k8R*LlzZiF4#dEgiY4Rk-^ErR~#O$v<+q4y96qXb;sI@ z?+?FaEvAm^HpDAg5AYkG)_I;*+W(>UB#XN{D-5WiD?ODoR<$2?V;+aL!m%*m*>hui zTfJ5Razr}WDlJ*@wDr7SXZ?prPL661x!-`{-&`jx{A)^<1MVWoisA%7>R{DsL|KJa zI#GLAJ?xgC8}p?d|FyFVeAs|C?^Nw!dMU|$?V*q5%u!$r-S+68F(6gG(GVy!ob0Oo z!bu4%3V_Hkv(~3bmXB3yayE$C-9_4`qlNM*LPxmk`;^HiYJPNA7Kn@S^^>mSomo|3v5YK7`+k>pekcq}ZSA3T3eSgZ_flLH8&PCIo4dz2~cUxa2 z)sDwB86V`7eJd-)(+hzeF0L<#w5<9wyD3|@^|&94lNc;&yp$wmLPfE|FqE!YPow_- zC1>32knk;&$0Nr9{b`;xy>zK23~r2rrJ}l7=5psIwj@}P)53-6!_G#dsID$t+&}Ae zaJD|J&6$q6(?97tNf3)rd^#ODZUoy_j8_i@BBADo^zj=Y2&KCqy~cGSvyTGcO)ZJcJ$EDdgHOv3hM6I>xQ=PXW|e z3FTYCnsBC}ty5SQHNm%Zr)9q^oqxB!Pm%W4{~4$=@{>l}hvWC;K_$P=aH81~BM^kL z9_$1y$gcFfKs(6Kh^NYJrdb{fAs)iA`%JX9GQwe15uQ@xbBc=C|DB;DXpLF04Ce8x zlVl0shpLh!28|HA%Dtl^O|6NJer1??Sb?EBALnT0S#gojrod&98%WBGH4P>?{k}(2 znjZh2hkfM*d)^!^1A;je3dc|l!`Gk=X}&<<1Z(Q>pDT+w#5iHSq znM-_gIKwmHk#(aVr(9myc)f9oo8ada6Wvl~7Ot!ifiPs$v~A4xYGds%DK;SS(n|=X z>7TR3A>DB}#v1CCS0C|V(nkGR^!MG;X?Ka`i%&Vy$uEdgg{BxvCcW^5UM{*R;IsnF zGtwY$iv6_f3Dmj@(MouG7Lv#Htu9lDgfJTy4#r@f#%>la8f6G@3g%tIG6@JVtboHj zAB>0}bXR$g%s9S-ye8#)dyHzwcsLAdoQqx#r zo|}J0b=7%GTytRYckAS*0T-14d2OEpx3D2P2=xk$NUtM_4?OufT!Ek4bkVuQ6gVd1 zU)gew;qCEJaKIUGi5%4Dg%Q+Pmvf!hHtv_l8j(oc_uu|nM?*cH(-E`f8qzFPKl~rs zoO0<)QqPPWPa4MQ)DH#oGd5bqGfYzY8DH02Cesf~>o6?$*jI>U(7+*|Bvz$1f}MVb zxrMVoR)00(b45?RecO+bnNP#G?-SbAp*y}NOK=KIJ0E@ce78-MS?5kGea>%ymec9h zTQRWHUI7==Xugv=Nn7A#`*~?d#A8cVLLn4Q!ZW`*%~Y>IS$u}~9Z9t8eWdr>N;bpO59$+}rDir6-&^n`t}I_}z&FV z6HGYK;DCF-{-;ZlbBXIt%=~fF7Z6|Nj-U1mI?VFH_U=O6 z+gW3OJWvU;lgsg+l^kZ!;DS*(*IlmNS;IRj?0UJb`}ak;?q)~%mZ?^uC16zx&~(uuyM6gDNu4VFgD8!yCi0h}-TVBU;-lHd0+=5m9Z+Z|0gwN_j)&48d}ZKh1ydgdLsotbj(7mRSSh)vYJwTWInj!Y55w=2pbm)iHZMiL{&A_X5se z*18?m9Qp9oe>5fhtGwaVyq<7v~`=V{E39W2G$O*$Rpx3<)k9m1cRK=JN!F2*G#fNO=RtW?DfCCN=d)QR%VsA@2ISA4 zXM^PnO0&Q@N=9$HCyUQFKY~yE?inmUUVnik^=>QKK40JvU9{fh>W+K?KhgmCVO{{9 zIG!WhzoK)PZQBcx(m60iQ^v83cft)}Ss*;@Gxej{q(eG3`0vUcGBSP@tVv?*IXp&w z<1bG0eY)eP;`r30kA(`i+RiAh#tU1=?%C@sx@c+YtqG~6`*rT^6YFCgN{TpBhiPM6 zVa4o$hz1~nmM4tayP@b?4LlSGeL6Q>CRZGp)wR z;r*MC5@Q!EVk^HmOZ#34zxhaIms^1cPqy!Bnq{PRgwXev+)lx{T2nDj)~ot969D;$5IFY7~#YIPt=ah+;}w5+OS_ z-%P^|QkHsqtmioDu&TrT3B+&U;^J@YF3A&7m6_J)2itQisr8k(? zxU_*N7P3cV^@mOb(I%cAJ&hk87lBkHf48p+C4i{Qq1st0uPbnVtmqA5u|FgM zcpkFPtE5Ho88sZiZ8yh?v$)UVN7XF#brxETqYBXH7-GP2Avd6b7OLr5=$pZ+-;xrK zqa^CMfdJq#;Yx1Dj!nF=vz`{|9}aPGZ@!+Rw<<5hW5vp-^>JNl6>4zP1hxwtaOdjU?#eA$ z!b(XIpc(Wc<4}O70|d@hjh!I`<~tb>Dhw_}ILc|-2?Y7f+Ya$$I-}Lq)zs^jy=eb# zS2=3xmeSX$B)|9|EX8e{tC&b5{`*r^8MuXCTDNgdm}~klJ#x7`_Hye>fT-1c6cZE-U@;y$ zx2J7@w#=()ZXu*D48V_0AS#U3hY`LYIF`99} z`i&cUo}Z31a`4g1!nJAEg(SDJ5T1}_s%n!>wS;lq4C~%?AM!^AAEj;6EH1HZTT?sL z453ocs+jb$%YT!jZ^doj_0-3&G}>$-Gf>kpoOGWNjhqy2@BTATbWIassC13OB_u4j z#B|)PXAb_&VsLAi%S4+Nwm0-=M5 zU zuZ$?7*@0|`(RpUn@x2Dk>TD7+?Lu&Ap!QC?mxOefMKbzFDm4A?O`zJNWo`tAIJYwl|>35Ml<1n zi1H=4bK9LQCLLdtD$7wn=EfpL`fsVT> z<&riGWqYJQM3?8FqUG5Knt-=?<4opA@5L<#VFN!Xh8KA8IZ!^J5$@}MHMliwiSoR9 z8pcch<SVC=8L6K0zP~tKbNa?s{9MO!$@xRxjO!s zJ_iwar8Ef9`_zFO>s>S$HX$g~nM9Y0-SEnuAboI~&flr_!^8TeJtzRzqDpyb#pfdN zcPEVB?1QHcl}BcaG0R<$>%}A>io_rA$YfX|WVheHY|bn(9}F}+#%g1fi*j%M!H?t8#sN$0a?ynj6wlYX$r`#JsK=Qo+d zXYsWs#&2A^AIqml^?DVA;(OXAGazI+2}y5@>&nsx$#Z)AgdALoKWsm;%&Lj|BJSuO z=cJ4aO2PzPh!1YGWmS0Tt!VqQzMgyRRElY}X_E-n7)8EypW$+PILF>6W zQ~oCBs^&}%EwWU|TE>skAeq{b_PqLJYvorl4`}xbIA?U*jW*udLcGpA08rWJYOSH2v=QVbhv!|uWs zdWPux!tQ~B4L5APU=i~#G4{^(X73pfo}@=bQ&*}{HvDu3n}+8o$=xq}9|__8{h%|) zF|EHVgltU`avrw1!O+ZsdIh^db?!n8k3;vCTI(4Kw_ywW+z55O8)}UuxcwQe~*WJdt*9I&nnO zP6sui#4sg`R>kD*si7s2k+QPSBATUXQ6!A*=hYs$)2%|@{-g}x=7c6BHm*U9dfI;E z$$)XIBCw6uOuPJ!9CmkUYdz65$%m|^)cA(gmcP+tEiZ%S{jxVuYmz*}+g~4V(L*1< zW(I6j>I7sfTnJD0suNE()OqJv-uO+7_QJRG*J04QIH7d4o;QA3*!)<0qDoX}I8K9y#v=j5^gY5#F!y=~I zUOO;Oi^97c*$zrvBrz)yf!T#ypX?gR9Bg=CHSeEJP))t6uRS{re^CTQNug^{z3A5C zw=XmSI6?V0HZy+3!vv=TT=E>8w){9UfT0C^YJo4nwAD&k4_2-s6#QH>iS0zdq)MYw2+P z=n}b$`vNS`;cZ+72Hjgj#W`EV!K>Cva%4a;Okj5y&%B9*(l%ztYxwS;C#iJ#JwEE} z{Wm!zL@r(0t>jfRaUbbNrv9$CEeCBCTI$Il&_=~?E+8UyX_E&ANveld?=i(IvMN_lR^zh;S@}bKpg7~U^L(vH ztX$rj_&-o9rABrh^<@sj$aSu8K*i#zrD{xock72qucsD1ve11t)2 z=G={$U}j4(PQPFL`RT=MRDcd8{?|-OfW%7e)i&gNThw~q48m@QN1d(ybxvl3EG=S( zB}cLm-6wjF*y2o#iS(XH_+4(Rpcz6A>Dg&R;J>DPP^UswT^i!{w}{p1VjmeExRmI# zLJ6R0TYz2$=lh+|lsi)A#5Og$zS;18@-YjzPwt%c1=Xf?)F@JmMuW372tpRzZq6|r zkZyTfw~=4x2;6^$IHz`+?d&|uC_UDDfMA*>@qQFNyc$Wx*c0YF9uUk%4Lxl6%{&&m zf~zfMLd5iI#XdFJAXI*qj~Y5g_QtHMhprBt0ia-3DLfUbq2 z2{MpMYOJyG8r(SGo zy0&s8QhyUsZ;RtssI5QX==*TnkxO#lU1TOKUG|b-U9{}`DN1spM5lz(P5je2g3l3d z--p&ShDCrm+eAt_7ICpM=>+<`>5ZzyX>hfUqioCL3ryDo9#LNBVDl@4c z1tP;$l^!)?6G^Z5w&I*l!#E=_@z_JR7Ki0fzJ<1h)f1$?C}}{qhFUobnRNATS4d^h zuZ(yj#-l}_KohgwPt0uY1)F|%+YGg?*Bxz?Wd=$7{Eji4%9TO@2tm4`(|`}Ctn-4P zIM+Q#bE4Q#-_^&dhm?(ZnbB2Z(o7y9=VMb_C!0ovCz}RZ*QYER3Tqi^`$J&X-28Ur zp7(1O0(_+9RZ`~uzQYrAZ9{R!LI}$~2ji-=ALJo7dKi=N_^)f- zO!YNL>Jug=DXwo~wEmAyJo(vXG+IfRcpR=2^ZvQ znEF3=#@SrxtOfEQB4A`)gBe^RW8|m2el`}bzOVM-GeMc^5hn?a0Y|Wu9n1f)pqKzS zSjf-&_Y$XCn^sN!s;EI90R}FX=C|yDi-) z!NXmDCw&75+fQJ-o?38yF{u>xx}uEUe$_lAjG9x? zDDL;DHC0POoTez7n`jlnj(57y>$j)(aR^=uDtc;(!=-0W@)>G12WU1C2!PUw626a{ zoZN?&PB`kEFAcq7HuyO6$a)`^Hk_nsK}x-% zO>^hmGB;9(E=3ve-ls4l&pwP9U)efmi@{oU9+wpn%O6d@|916kzoWxd@GRoNmhP)e zcsiN?RCvVIX_@1cIy#60s4?@3P4X63dn$Ep{21iLTa$W?S@+>>-5O+|X)_kLz8?be zXD9**;|+Cngsw0jWdjxt$`XS-|` zqGz!%FS(9qwdlgQAVZ_?CC~IN8EDFkAyRe(`=BMV<21nk6Z+22Y`vsZbaLL7_WC&S zDN@FDsJ!LX9Vp27%ofpez*zfyRpNEI{7v;Y9H_5AbA^}h7o4J|pO`ZAzE+sVu+ZbA z%;?mDRE2`n9^uqiX*V$HW(m7vVc{c#tFrpuT$f>ml{0px0Q1%Xz!n@z#os;l#`=#3 zpI%F+PQ;FGP&Ps3y>s%iQs$*NisBLf2*uL-0)Z2i8l{N1$Fn)s@3)LwQv{7>+f4^m z(p3l&W)tRR^>OUYnv5Xlc>o|vt}xZ3ps-&`w+WYwtTq-%L}xmCRp_grw-^L!gX;7u#u1$ zEt~s6M30DvL5;$$%1=L&l0&UF%@keCnUfO-6~iT2-Ab3^^{T?-(Xk2PBSQXoNs?Z_ z*E7^UYJs5f6D`cNxMZ}}W*uQpw`QDam6NaIm!?Z8W)ZURmINwX0jq0~wNIg6f&P!| zNwjJumLX|Ed?%5jQT}lcvyl$pO=?3s&#cB?I1QXP6K)y$gvPw&E$15Vx-b9HNNy;7 zAr4-X!Qq^(3)0ZC-+0$kJNM(G{PpvN)+_vFf+y@gZ`-RO4H>jTX>>F{Xv_Ape!FoI zCnx~z8%^j{oAxBhP+Y@0(B5EV-^(Z zXdqBX6cKyh&b5Mx|1!q->5*B^1Gwy{_u3_6 z)EINOAmvNQV47%V7_Di}d&of8O)SvML-S_Wt=C?yePtAy)PqY=0d<3}IP8vQOZ(OO zHxgHuQ%}=f+l8l!Y86-U)`P5X1=n()?^*3FD1vAJ{(-6ASw`uCl;8{_KzpJU-N|zi zQ(v3z{|cp-)99Ic+V!Pb1p!h6&{AAcuxlAnoN1B)AUA|s=c5ISmvJ`9xg*CbnY;PM4MMN& z{+Q-U9tz(kzjMydT1myZEAc`(DQ6dQWbpn;Zb&hM+t3@pufB{cnC9Z$?^$y0&Sp?m zk|K&lLN?k`oGpmSecgO76-?%Q1BVK$}prhdFA) zcPHay0Epi9Tu9P#oUA-Q4DH2eBGS7_*lk@KlFEiZyCmpq_+8-3y3d6H=aO|4j$WjT zKL9CgW%N(g!w22{e@w>zLmGmw-JE=9OOE|yRFV1@tp^3-+|7{x1s;O`3vvAW1C4js zK_Eo~6{QA%9IEIJw+4Y!RJi`;h~k4NaBx95!1X^bI-EOypY?&5{@>UPh@bvD1`Nax zfHL8K$HRm1{*HkO|33I>aX=VcfJgf886O0SCII09?+FMA3gE>26XO6gfU^F*E+`;? cll9Mxx3?e=IP0GoM}Z-@|Hf5+z79D42i5iA1poj5 literal 0 HcmV?d00001 From 31d756faad11a22fe006684f004530b6e128a8b1 Mon Sep 17 00:00:00 2001 From: Samuel Abels Date: Fri, 16 Oct 2026 18:04:20 +0200 Subject: [PATCH 100/104] docs(i18n): embed the polygon screenshot in every translation --- .../current/features/sketcher/polygon.md | 2 ++ .../current/features/sketcher/polygon.md | 2 ++ .../current/features/sketcher/polygon.md | 2 ++ .../current/features/sketcher/polygon.md | 2 ++ .../current/features/sketcher/polygon.md | 2 ++ .../current/features/sketcher/polygon.md | 2 ++ 6 files changed, 12 insertions(+) diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md b/website/i18n/de/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md index 982a0db62..8d6f17582 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md @@ -9,6 +9,8 @@ description: Das **Polygon**-Werkzeug (`G+X`) zeichnet ein regelmäßiges Polygon, etwa ein Sechseck für einen Untersetzer, oder einen Stern. +![Ein Sechseck und ein fünfzackiger Stern](/screenshots/addons-sketcher-tool-polygon.webp) + ## Ein Polygon zeichnen Klicke dorthin, wo der Mittelpunkt liegen soll, bewege den Zeiger und klicke erneut, oder drücke am diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md b/website/i18n/es/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md index 5124df27f..871fa265b 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md @@ -9,6 +9,8 @@ description: La herramienta **polígono** (`G+X`) dibuja un polígono regular, como un hexágono para un posavasos, o una estrella. +![Un hexágono y una estrella de cinco puntas](/screenshots/addons-sketcher-tool-polygon.webp) + ## Dibujar un polígono Haga clic donde debe estar el centro, mueva el puntero y vuelva a hacer clic, o pulse en el centro, diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md index 6b0e678c8..48459dcdc 100644 --- a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md @@ -8,6 +8,8 @@ description: **बहुभुज** उपकरण (`G+X`) एक नियमित बहुभुज बनाता है, जैसे कोस्टर के लिए षट्भुज, या एक तारा. +![एक षट्भुज और एक पाँच कोनों वाला तारा](/screenshots/addons-sketcher-tool-polygon.webp) + ## बहुभुज बनाना जहाँ केंद्र होना चाहिए वहाँ क्लिक करें, पॉइंटर को घुमाएँ और फिर से क्लिक करें, या केंद्र पर दबाएँ, diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md index 69d39d25a..1be112fed 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md @@ -9,6 +9,8 @@ description: A ferramenta de **polígono** (`G+X`) desenha um polígono regular, como um hexágono para um porta-copos, ou uma estrela. +![Um hexágono e uma estrela de cinco pontas](/screenshots/addons-sketcher-tool-polygon.webp) + ## Desenhar um polígono Clique onde o centro deve ficar, mova o ponteiro e clique novamente, ou pressione no centro, arraste diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md index 0b3c8d331..f164d9981 100644 --- a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md @@ -9,6 +9,8 @@ description: Інструмент **багатокутника** (`G+X`) малює правильний багатокутник, наприклад шестикутник для підставки, або зірку. +![Шестикутник та п'ятипроменева зірка](/screenshots/addons-sketcher-tool-polygon.webp) + ## Малювання багатокутника Клацніть там, де має бути центр, перемістіть вказівник і клацніть ще раз, або натисніть у центрі, diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md index aa813d6d5..6a77eb389 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md @@ -7,6 +7,8 @@ description: **多边形**工具(`G+X`)可绘制正多边形(例如用作杯垫的六边形)或星形。 +![一个六边形和一个五角星](/screenshots/addons-sketcher-tool-polygon.webp) + ## 绘制多边形 在中心所在位置单击,移动指针后再次单击;或在中心按下、拖动并释放。指针确定第一个角,因此同时决定大小和旋转。新多边形有 6 个角。 From b1f45fc2ed0091e3f4e9d94419ff85d513c2ff94 Mon Sep 17 00:00:00 2001 From: Samuel Abels Date: Sat, 17 Oct 2026 12:18:26 +0200 Subject: [PATCH 101/104] fix(machine): evaluate jog limits in work coordinates The jog limit check compared the controller's raw machine position (MPos) against the soft limits, which are work-area bounds. On machines whose machine coordinates sit offset from the work origin (e.g. a GRBL that homes at the max end and reports negative machine positions), jogs toward the origin appeared outside the bounds and were silently clamped to zero, leaving the left/forward jog buttons permanently orange and blocked (#574). Subtract the active WCS offset before the check, so soft limits are evaluated in the same coordinate space the position display uses. The Z travel check keeps using machine coordinates. The soft limits toggle only cleared the custom bounds; clamping against the work area stayed active with no way to turn it off. The toggle now enables and disables jog limit checks as a whole, and the state is persisted in the device profile. --- rayforge/machine/device/profile.py | 14 +++ rayforge/machine/models/machine.py | 11 ++- rayforge/ui_gtk/machine/hardware_page.py | 19 ++-- .../machine/wizard_pages/hardware_page.py | 17 ++-- tests/machine/device/test_device_profile.py | 31 +++++++ tests/machine/models/test_machine.py | 86 +++++++++++++++++++ website/docs/machine/hardware.md | 8 +- 7 files changed, 168 insertions(+), 18 deletions(-) diff --git a/rayforge/machine/device/profile.py b/rayforge/machine/device/profile.py index 1e0d69606..09b873129 100644 --- a/rayforge/machine/device/profile.py +++ b/rayforge/machine/device/profile.py @@ -361,6 +361,15 @@ class MachineConfig: lambda m, v: m.set_soft_limits(*v), ), ) + soft_limits_enabled: bool | None = field( + default=None, + metadata=_binding( + _("Hardware"), + _("Enable Soft Limits"), + lambda m: m.soft_limits_enabled, + lambda m, v: m.set_soft_limits_enabled(v), + ), + ) origin: Origin | None = field( default=None, metadata=_binding( @@ -537,6 +546,9 @@ def from_machine(cls, machine: Machine) -> "MachineConfig": axis_extents=machine.axis_extents, work_margins=work_margins, soft_limits=machine.soft_limits, + soft_limits_enabled=( + None if machine.soft_limits_enabled else False + ), origin=machine.origin, panel_orientation=machine.panel.orientation, max_travel_speed=machine.max_travel_speed, @@ -717,6 +729,8 @@ def create_machine(self, context: "RayforgeContext") -> Machine: m.set_work_margins(*cfg.work_margins) if cfg.soft_limits is not None: m.set_soft_limits(*cfg.soft_limits) + if cfg.soft_limits_enabled is not None: + m.set_soft_limits_enabled(cfg.soft_limits_enabled) if cfg.origin is not None: m.origin = cfg.origin if cfg.panel_orientation is not None: diff --git a/rayforge/machine/models/machine.py b/rayforge/machine/models/machine.py index 6b6117520..905760610 100644 --- a/rayforge/machine/models/machine.py +++ b/rayforge/machine/models/machine.py @@ -106,6 +106,13 @@ def _clamp_jog_distance( return target - pos +def _shift_pos(pos: float | None, offset: float) -> float | None: + """Convert a machine position into work coordinates.""" + if pos is None: + return None + return pos - offset + + def _raise_error(*args, **kwargs): raise RuntimeError("Cannot schedule from worker process") @@ -1198,7 +1205,9 @@ def _adjust_jog_distance_for_limits( return distance current_pos = self.device_state.machine_pos - x_pos, y_pos = current_pos[0], current_pos[1] + off_x, off_y, _ = self.get_active_wcs_offset() + x_pos = _shift_pos(current_pos[0], off_x) + y_pos = _shift_pos(current_pos[1], off_y) x_min, y_min, x_max, y_max = self.get_soft_limits() adjusted_distance = distance diff --git a/rayforge/ui_gtk/machine/hardware_page.py b/rayforge/ui_gtk/machine/hardware_page.py index 51732a9c5..a0527bae5 100644 --- a/rayforge/ui_gtk/machine/hardware_page.py +++ b/rayforge/ui_gtk/machine/hardware_page.py @@ -220,19 +220,19 @@ def __init__(self, machine: Machine, **kwargs): soft_limits_group.set_description( _( "Configurable safety bounds for jogging. " - "Leave disabled to use work surface bounds." + "Leave disabled to jog without limit checks." ) ) self.add(soft_limits_group) self.soft_limits_enabled_row = Adw.SwitchRow() - self.soft_limits_enabled_row.set_title(_("Enable Custom Soft Limits")) + self.soft_limits_enabled_row.set_title(_("Enable Soft Limits")) self.soft_limits_enabled_row.set_subtitle( - _("Override work surface bounds with custom limits") + _("Limit jogging to custom bounds instead of the work surface") ) - has_custom_limits = self.machine.soft_limits is not None + limits_enabled = self.machine.soft_limits_enabled limits = self.machine.soft_limits or (0, 0, *self.machine.axis_extents) - self.soft_limits_enabled_row.set_active(has_custom_limits) + self.soft_limits_enabled_row.set_active(limits_enabled) self.soft_limits_enabled_row.connect( "notify::active", self.on_soft_limits_enabled_changed ) @@ -245,7 +245,7 @@ def __init__(self, machine: Machine, **kwargs): value_in_base=limits[0], ) self.soft_x_min_row.value_changed.connect(self.on_soft_limits_changed) - self.soft_x_min_row.set_sensitive(has_custom_limits) + self.soft_x_min_row.set_sensitive(limits_enabled) soft_limits_group.add(self.soft_x_min_row) self.soft_y_min_row = LengthSpinRow( @@ -255,7 +255,7 @@ def __init__(self, machine: Machine, **kwargs): value_in_base=limits[1], ) self.soft_y_min_row.value_changed.connect(self.on_soft_limits_changed) - self.soft_y_min_row.set_sensitive(has_custom_limits) + self.soft_y_min_row.set_sensitive(limits_enabled) soft_limits_group.add(self.soft_y_min_row) self.soft_x_max_row = LengthSpinRow( @@ -265,7 +265,7 @@ def __init__(self, machine: Machine, **kwargs): value_in_base=limits[2], ) self.soft_x_max_row.value_changed.connect(self.on_soft_limits_changed) - self.soft_x_max_row.set_sensitive(has_custom_limits) + self.soft_x_max_row.set_sensitive(limits_enabled) soft_limits_group.add(self.soft_x_max_row) self.soft_y_max_row = LengthSpinRow( @@ -275,7 +275,7 @@ def __init__(self, machine: Machine, **kwargs): value_in_base=limits[3], ) self.soft_y_max_row.value_changed.connect(self.on_soft_limits_changed) - self.soft_y_max_row.set_sensitive(has_custom_limits) + self.soft_y_max_row.set_sensitive(limits_enabled) soft_limits_group.add(self.soft_y_max_row) self.machine.changed.connect(self._on_machine_changed) @@ -380,6 +380,7 @@ def on_wcs_origin_is_workarea_origin_changed(self, row, _): def on_soft_limits_enabled_changed(self, row, _): enabled = row.get_active() + self.machine.set_soft_limits_enabled(enabled) self.soft_x_min_row.set_sensitive(enabled) self.soft_y_min_row.set_sensitive(enabled) self.soft_x_max_row.set_sensitive(enabled) diff --git a/rayforge/ui_gtk/machine/wizard_pages/hardware_page.py b/rayforge/ui_gtk/machine/wizard_pages/hardware_page.py index b9f3b05f4..a19e87fd0 100644 --- a/rayforge/ui_gtk/machine/wizard_pages/hardware_page.py +++ b/rayforge/ui_gtk/machine/wizard_pages/hardware_page.py @@ -163,14 +163,16 @@ def build_ui(self) -> None: title=_("Soft Limits"), description=_( "Configurable safety bounds for jogging. " - "Leave disabled to use work surface bounds." + "Leave disabled to jog without limit checks." ), ) self.content.append(self.soft_limits_group) self.soft_limits_enabled_row = Adw.SwitchRow( - title=_("Enable Custom Soft Limits"), - subtitle=_("Override work-surface bounds with custom limits"), + title=_("Enable Soft Limits"), + subtitle=_( + "Limit jogging to custom bounds instead of the work surface" + ), ) self.soft_limits_enabled_row.connect( "notify::active", self._on_soft_limits_toggle @@ -336,14 +338,17 @@ def enter(self, profile: DeviceProfile) -> None: self.margin_bottom_row.set_value_in_base_units(margins[3]) soft = mc.soft_limits + if mc.soft_limits_enabled is not None: + enabled = mc.soft_limits_enabled + else: + enabled = soft is not None + self.soft_limits_enabled_row.set_active(enabled) if soft: - self.soft_limits_enabled_row.set_active(True) self.soft_x_min_row.set_value_in_base_units(soft[0]) self.soft_y_min_row.set_value_in_base_units(soft[1]) self.soft_x_max_row.set_value_in_base_units(soft[2]) self.soft_y_max_row.set_value_in_base_units(soft[3]) else: - self.soft_limits_enabled_row.set_active(False) self.soft_x_min_row.set_value_in_base_units(0.0) self.soft_y_min_row.set_value_in_base_units(0.0) self.soft_x_max_row.set_value_in_base_units( @@ -410,6 +415,7 @@ def apply_to_profile(self, profile: DeviceProfile) -> bool: mc.work_margins = None if self.soft_limits_enabled_row.get_active(): + mc.soft_limits_enabled = True mc.soft_limits = ( self.soft_x_min_row.get_value_in_base_units(), self.soft_y_min_row.get_value_in_base_units(), @@ -417,6 +423,7 @@ def apply_to_profile(self, profile: DeviceProfile) -> bool: self.soft_y_max_row.get_value_in_base_units(), ) else: + mc.soft_limits_enabled = False mc.soft_limits = None travel = self.travel_speed_row.get_value_in_base_units() diff --git a/tests/machine/device/test_device_profile.py b/tests/machine/device/test_device_profile.py index 8b0a96193..a41ad9265 100644 --- a/tests/machine/device/test_device_profile.py +++ b/tests/machine/device/test_device_profile.py @@ -4,6 +4,7 @@ import pytest import yaml +from raygeo.ops.axis import Axis import rayforge from rayforge.machine.device.manager import DeviceProfileManager @@ -632,6 +633,18 @@ def test_export_roundtrip(self, tmp_path): assert pkg2.meta.notes == "Profile setup notes." assert pkg2.machine_config.axis_extents == (400, 300) + def test_soft_limits_disabled_loads(self, tmp_path): + _make_device( + tmp_path, + name="Toggle", + machine_extra={"soft_limits_enabled": False}, + ) + mgr = DeviceProfileManager([tmp_path]) + mgr.discover() + pkg = mgr.get("Toggle") + assert pkg is not None + assert pkg.machine_config.soft_limits_enabled is False + FALCON_BOUNDS_LINE = ( "; Bounds: X{job.extents[0]} Y{job.extents[1]} " @@ -831,6 +844,24 @@ def test_export_ruida_machine_no_dialect(self, tmp_path): class TestCreateMachine: + @pytest.mark.asyncio + async def test_create_machine_applies_soft_limits_toggle( + self, tmp_path, lite_context, task_mgr + ): + """A profile with the soft limits toggle off must produce a + machine that does not clamp or flag jogs (issue #574).""" + device_dir = _make_device( + tmp_path, + name="No Soft Limits", + machine_extra={"soft_limits_enabled": False}, + ) + pkg = DeviceProfile.from_path(device_dir) + m = pkg.create_machine(lite_context) + await _wait_for_tasks(task_mgr) + + assert m.soft_limits_enabled is False + assert m.would_jog_exceed_limits(Axis.X, 9999.0) is False + @pytest.mark.asyncio async def test_create_machine_gcode_has_dialect( self, tmp_path, lite_context, task_mgr diff --git a/tests/machine/models/test_machine.py b/tests/machine/models/test_machine.py index 648d14fc8..16706863e 100644 --- a/tests/machine/models/test_machine.py +++ b/tests/machine/models/test_machine.py @@ -2561,6 +2561,92 @@ def test_would_jog_exceed_limits_matches_clamp_outside_range( is expected ) + @pytest.mark.parametrize( + "work_pos, axis, distance, expected", + [ + # Work position X 100, Y 170, far from every edge: all + # four directions jog freely (issue #574) + ((100, 170), Axis.X, -10.0, -10.0), + ((100, 170), Axis.X, 10.0, 10.0), + ((100, 170), Axis.Y, -10.0, -10.0), + ((100, 170), Axis.Y, 10.0, 10.0), + # Near the max edge: outward shortens, inward is untouched + ((390, 170), Axis.X, 10.0, 5.0), + ((390, 170), Axis.X, -10.0, -10.0), + # Near the origin: negative jogs shorten + ((3, 170), Axis.X, -10.0, -3.0), + ((100, 3), Axis.Y, -10.0, -3.0), + ], + ) + def test_adjust_jog_distance_uses_work_coordinates( + self, isolated_machine: Machine, work_pos, axis, distance, expected + ): + """ + Soft limits are work-area bounds, so the jog check must run in + the work coordinate space the position display uses - not in + raw machine coordinates, which differ by the active WCS offset + (issue #574). + + The machine coordinates simulate a GRBL that homes at the max + end and reports negative machine positions: the G54 offset is + (-387, -387), so work = machine + 387. + """ + isolated_machine.set_axis_extents(395, 395) + isolated_machine.update_wcs_offsets_batch( + {"G54": (-387.0, -387.0, 0.0)} + ) + isolated_machine.device_state.machine_pos = ( + work_pos[0] - 387.0, + work_pos[1] - 387.0, + 0.0, + ) + + assert isolated_machine._adjust_jog_distance_for_limits( + axis, distance + ) == pytest.approx(expected) + + def test_would_jog_exceed_limits_uses_work_coordinates( + self, isolated_machine: Machine + ): + """ + The orange jog warning keys off the work position, so a machine + well inside the work area is not flagged even when its raw + machine coordinates sit outside the work-area bounds (issue + #574). + """ + isolated_machine.set_axis_extents(395, 395) + isolated_machine.update_wcs_offsets_batch( + {"G54": (-387.0, -387.0, 0.0)} + ) + isolated_machine.device_state.machine_pos = (-287.0, -217.0, 0.0) + + assert isolated_machine.would_jog_exceed_limits(Axis.X, -10.0) is False + assert isolated_machine.would_jog_exceed_limits(Axis.X, 10.0) is False + + isolated_machine.device_state.machine_pos = (-384.0, -217.0, 0.0) + assert isolated_machine.would_jog_exceed_limits(Axis.X, -10.0) is True + assert isolated_machine.would_jog_exceed_limits(Axis.X, 10.0) is False + + def test_disabled_soft_limits_skip_work_coordinate_check( + self, isolated_machine: Machine + ): + """ + With the soft limits toggle off, no jog is clamped or flagged + no matter where the machine coordinates sit (issue #574). + """ + isolated_machine.set_axis_extents(395, 395) + isolated_machine.set_soft_limits_enabled(False) + isolated_machine.update_wcs_offsets_batch( + {"G54": (-387.0, -387.0, 0.0)} + ) + isolated_machine.device_state.machine_pos = (-287.0, -217.0, 0.0) + + assert ( + isolated_machine._adjust_jog_distance_for_limits(Axis.X, -10.0) + == -10.0 + ) + assert isolated_machine.would_jog_exceed_limits(Axis.X, -10.0) is False + def test_set_z_extents_updates_travel_range( self, isolated_machine: Machine ): diff --git a/website/docs/machine/hardware.md b/website/docs/machine/hardware.md index f230a34a8..a625e63ac 100644 --- a/website/docs/machine/hardware.md +++ b/website/docs/machine/hardware.md @@ -94,13 +94,15 @@ margins. ## Soft Limits Configurable safety bounds for jogging the machine head. When enabled, the jog controls will prevent -movement outside these limits. +movement outside these limits. When disabled, jogging is not limited. -- **Enable Custom Soft Limits**: Toggle to use custom limits instead of the work surface bounds +- **Enable Soft Limits**: Toggle jog limit checks on or off - **X/Y Min**: Minimum coordinate for each axis - **X/Y Max**: Maximum coordinate for each axis -Soft limits are automatically constrained to stay within the axis extents (0 to extent value). +Soft limits are evaluated in the work coordinate system shown in the position display, so they stay +put when the work origin moves. When enabled without custom bounds, the work surface bounds are +used. Limits are automatically constrained to stay within the axis extents (0 to extent value). :::tip From ec05fd0f792ed30f8eb674df189ced42c4df16c1 Mon Sep 17 00:00:00 2001 From: Samuel Abels Date: Sat, 17 Oct 2026 12:33:54 +0200 Subject: [PATCH 102/104] fix(appimage): repair TLS and Wayland keymap lookup in AppRun The bundled conda environment keeps absolute build-machine paths inside its libraries, so they are dead inside the AppImage. AppRun now exports XKB_CONFIG_ROOT so GTK can compile keymaps on Wayland instead of segfaulting, and points SSL_CERT_FILE at the real cacert.pem location (env/ssl, not env/etc/ssl) so TLS verification works for the update and addon registry checks. Adds an --apprun-selfcheck mode that verifies the CA file and an evdev keymap compilation inside the launch environment; the CI smoke test now runs it to catch regressions. Fixes #578 --- .github/workflows/build-appimage.yml | 1 + scripts/build-appimage.sh | 67 +++++++++++++++++++++++++++- 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-appimage.yml b/.github/workflows/build-appimage.yml index 71648164e..5f6814c79 100644 --- a/.github/workflows/build-appimage.yml +++ b/.github/workflows/build-appimage.yml @@ -75,6 +75,7 @@ jobs: run: | xvfb-run -a ./dist/Rayforge-*.AppImage --version xvfb-run -a ./dist/Rayforge-*.AppImage --exit + ./dist/Rayforge-*.AppImage --apprun-selfcheck release: name: Attach AppImage to GitHub Release diff --git a/scripts/build-appimage.sh b/scripts/build-appimage.sh index 1988b9e82..1ac560682 100755 --- a/scripts/build-appimage.sh +++ b/scripts/build-appimage.sh @@ -116,8 +116,13 @@ export GI_TYPELIB_PATH="$BUNDLE/lib/girepository-1.0" export XDG_DATA_DIRS="$BUNDLE/share:${XDG_DATA_DIRS:-/usr/local/share:/usr/share}" export LOCPATH="$BUNDLE/share/locale${LOCPATH:+:$LOCPATH}" -if [ -f "$BUNDLE/etc/ssl/cacert.pem" ]; then - export SSL_CERT_FILE="$BUNDLE/etc/ssl/cacert.pem" +XKB_DIR="$BUNDLE/share/xkeyboard-config-2" +if [ -d "$XKB_DIR" ]; then + export XKB_CONFIG_ROOT="$XKB_DIR" +fi + +if [ -f "$BUNDLE/ssl/cacert.pem" ]; then + export SSL_CERT_FILE="$BUNDLE/ssl/cacert.pem" fi if [ -d "$BUNDLE/lib/gio/modules" ]; then @@ -153,6 +158,64 @@ cat > "$FONTCONFIG_CONF" < Date: Sat, 17 Oct 2026 12:40:05 +0200 Subject: [PATCH 103/104] docs(website): translate soft limits change for all languages Mirror the updated Soft Limits section of the hardware settings page in the German, Spanish, French, Hindi, Brazilian Portuguese, Ukrainian and Chinese docs. --- .../current/machine/hardware.md | 12 +++++++----- .../current/machine/hardware.md | 13 ++++++++----- .../current/machine/hardware.md | 13 ++++++++----- .../current/machine/hardware.md | 9 ++++++--- .../current/machine/hardware.md | 11 ++++++----- .../current/machine/hardware.md | 11 ++++++----- .../current/machine/hardware.md | 6 +++--- 7 files changed, 44 insertions(+), 31 deletions(-) diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/machine/hardware.md b/website/i18n/de/docusaurus-plugin-content-docs/current/machine/hardware.md index 580a1beb8..9d894a662 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/machine/hardware.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/machine/hardware.md @@ -92,15 +92,17 @@ abzüglich der Ränder berechnet. ## Software-Limits Konfigurierbare Sicherheitsgrenzen für das Verfahren des Maschinenkopfes. Wenn aktiviert, verhindern -die Jog-Steuerungen Bewegungen außerhalb dieser Grenzen. +die Jog-Steuerungen Bewegungen außerhalb dieser Grenzen. Wenn deaktiviert, ist das Verfahren nicht +begrenzt. -- **Benutzerdefinierte Software-Limits aktivieren**: Umschalten, um benutzerdefinierte Limits statt - der Arbeitsflächen-Grenzen zu verwenden +- **Software-Limits aktivieren**: Schaltet die Jog-Limit-Prüfung ein oder aus - **X/Y Min**: Minimale Koordinate für jede Achse - **X/Y Max**: Maximale Koordinate für jede Achse -Software-Limits werden automatisch eingeschränkt, um innerhalb der Achsenbereiche zu bleiben (0 bis -Bereichswert). +Software-Limits werden im Koordinatensystem ausgewertet, das in der Positionsanzeige angezeigt wird, +und bleiben daher beim Verschieben des Arbeitsursprungs an ihrem Platz. Ohne benutzerdefinierte +Grenzen werden bei Aktivierung die Grenzen der Arbeitsfläche verwendet. Limits werden automatisch +eingeschränkt, um innerhalb der Achsenbereiche zu bleiben (0 bis Bereichswert). :::tip diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/machine/hardware.md b/website/i18n/es/docusaurus-plugin-content-docs/current/machine/hardware.md index 6215c1f3a..cfd8e8184 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/machine/hardware.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/machine/hardware.md @@ -93,15 +93,18 @@ extensión de los ejes menos los márgenes. ## Límites Suaves Límites de seguridad configurables para desplazar la cabeza de la máquina. Cuando están habilitados, -los controles de desplazamiento impedirán el movimiento fuera de estos límites. +los controles de desplazamiento impedirán el movimiento fuera de estos límites. Cuando están +deshabilitados, el desplazamiento no está limitado. -- **Habilitar Límites Suaves Personalizados**: Alternar para usar límites personalizados en lugar de - los límites de la superficie de trabajo +- **Habilitar Límites Suaves**: Activa o desactiva la comprobación de límites de desplazamiento - **X/Y Mín**: Coordenada mínima para cada eje - **X/Y Máx**: Coordenada máxima para cada eje -Los límites suaves se restringen automáticamente para permanecer dentro de la extensión de los ejes -(0 al valor de extensión). +Los límites suaves se evalúan en el sistema de coordenadas de trabajo que muestra el indicador de +posición, por lo que se mantienen en su lugar cuando el origen de trabajo se mueve. Sin límites +personalizados, al habilitarlos se usan los límites de la superficie de trabajo. Los límites se +restringen automáticamente para permanecer dentro de la extensión de los ejes (0 al valor de +extensión). :::tip diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/machine/hardware.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/machine/hardware.md index ce14ff1b0..16bb55ec4 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/machine/hardware.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/machine/hardware.md @@ -93,15 +93,18 @@ l'étendue des axes moins les marges. ## Limites Logicielles Limites de sécurité configurables pour le déplacement de la tête machine. Lorsqu'activées, les -commandes de déplacement empêcheront tout mouvement en dehors de ces limites. +commandes de déplacement empêcheront tout mouvement en dehors de ces limites. Lorsqu'elles sont +désactivées, le déplacement n'est pas limité. -- **Activer les Limites Logicielles Personnalisées** : Basculer pour utiliser des limites - personnalisées au lieu des limites de la surface de travail +- **Activer les Limites Logicielles** : Active ou désactive la vérification des limites de + déplacement - **X/Y Min** : Coordonnée minimum pour chaque axe - **X/Y Max** : Coordonnée maximum pour chaque axe -Les limites logicielles sont automatiquement contraintes pour rester dans l'étendue des axes (0 à la -valeur d'étendue). +Les limites logicielles sont évaluées dans le système de coordonnées de travail affiché dans +l'affichage de position ; elles restent donc en place lorsque l'origine de travail se déplace. Sans +limites personnalisées, l'activation utilise les limites de la surface de travail. Les limites sont +automatiquement contraintes pour rester dans l'étendue des axes (0 à la valeur d'étendue). :::tip diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/machine/hardware.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/machine/hardware.md index d33ae3964..95595e43b 100644 --- a/website/i18n/hi/docusaurus-plugin-content-docs/current/machine/hardware.md +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/machine/hardware.md @@ -94,13 +94,16 @@ description: ## सॉफ़्ट लिमिट मशीन हेड जॉग करने के लिए कॉन्फ़िगर योग्य सुरक्षा सीमाएँ. सक्षम होने पर, जॉग नियंत्रण इन सीमाओं के -बाहर गति रोक देंगे. +बाहर गति रोक देंगे. अक्षम होने पर, जॉगिंग सीमित नहीं होती है. -- **कस्टम सॉफ़्ट लिमिट सक्षम करें**: कार्य सतह सीमाओं के बजाय कस्टम लिमिट उपयोग करने का टॉगल +- **सॉफ़्ट लिमिट सक्षम करें**: जॉग लिमिट जाँच चालू या बंद करता है - **X/Y न्यूनतम**: प्रत्येक अक्ष का न्यूनतम निर्देशांक - **X/Y अधिकतम**: प्रत्येक अक्ष का अधिकतम निर्देशांक -सॉफ़्ट लिमिट अक्ष विस्तारों (0 से विस्तार मान तक) के भीतर रहने के लिए स्वतः सीमित होती हैं. +सॉफ़्ट लिमिट का मूल्यांकन उस कार्य निर्देशांक प्रणाली में किया जाता है जो स्थिति प्रदर्शन में दिखाई +जाती है, इसलिए कार्य मूल बिंदु हिलने पर ये अपने स्थान पर बने रहते हैं. बिना कस्टम सीमाओं के सक्षम +करने पर, कार्य सतह सीमाएँ उपयोग की जाती हैं. सीमाएँ अक्ष विस्तारों (0 से विस्तार मान तक) के भीतर +रहने के लिए स्वतः सीमित होती हैं. :::tip diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/machine/hardware.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/machine/hardware.md index d67eb885d..18f0acb22 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/machine/hardware.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/machine/hardware.md @@ -93,15 +93,16 @@ dos eixos menos as margens. ## Limites de Software Limites de segurança configuráveis para mover a cabeça da máquina. Quando habilitados, os controles -de jog impedirão movimento fora desses limites. +de jog impedirão movimento fora desses limites. Quando desabilitados, o jog não é limitado. -- **Habilitar Limites de Software Personalizados**: Alternar para usar limites personalizados em vez - dos limites da superfície de trabalho +- **Habilitar Limites de Software**: Ativa ou desativa a verificação dos limites de jog - **X/Y Mín**: Coordenada mínima para cada eixo - **X/Y Máx**: Coordenada máxima para cada eixo -Os limites de software são automaticamente restringidos para ficar dentro da extensão dos eixos (0 -ao valor de extensão). +Os limites de software são avaliados no sistema de coordenadas de trabalho mostrado no indicador de +posição, por isso permanecem no lugar quando a origem de trabalho se move. Sem limites +personalizados, ao habilitar são usados os limites da superfície de trabalho. Os limites são +automaticamente restringidos para ficar dentro da extensão dos eixos (0 ao valor de extensão). :::tip diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/machine/hardware.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/machine/hardware.md index d4190056f..9cb83f691 100644 --- a/website/i18n/uk/docusaurus-plugin-content-docs/current/machine/hardware.md +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/machine/hardware.md @@ -87,15 +87,16 @@ ## Програмні обмеження Налаштовані межі безпеки для переміщення головки машини. Коли увімкнено, елементи керування -переміщенням запобігатимуть руху за межами цих обмежень. +переміщенням запобігатимуть руху за межами цих обмежень. Коли вимкнено, переміщення не обмежується. -- **Увімкнути власні програмні обмеження**: Перемикач для використання власних обмежень замість меж - робочої поверхні +- **Увімкнути програмні обмеження**: Вмикає або вимикає перевірку обмежень переміщення - **X/Y Мін**: Мінімальна координата для кожної осі - **X/Y Макс**: Максимальна координата для кожної осі -Програмні обмеження автоматично обмежуються, щоб залишатися в межах діапазонів осей (від 0 до -значення діапазону). +Програмні обмеження оцінюються в системі робочих координат, показаній на дисплеї позиції, тому вони +залишаються на місці під час переміщення робочого початку. Без власних обмежень під час увімкнення +використовуються межі робочої поверхні. Обмеження автоматично обмежуються, щоб залишатися в межах +діапазонів осей (від 0 до значення діапазону). :::порада Використовуйте програмні обмеження для захисту ділянок вашої робочої поверхні, які ніколи не повинні досягатися під час переміщення, наприклад, ділянок з оснащенням або чутливим обладнанням. diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/machine/hardware.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/machine/hardware.md index 6bf845eba..3a2d08c76 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/machine/hardware.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/machine/hardware.md @@ -74,13 +74,13 @@ ## 软限位 -可配置的点动机器头的安全边界。启用后,点动控制将阻止超出这些限制的移动。 +可配置的点动机器头的安全边界。启用后,点动控制将阻止超出这些限制的移动。禁用后,点动不受限制。 -- **启用自定义软限位**:切换以使用自定义限制而不是工作表面边界 +- **启用软限位**:开启或关闭点动限制检查 - **X/Y 最小值**:每个轴的最小坐标 - **X/Y 最大值**:每个轴的最大坐标 -软限位自动限制在轴范围内(0 到范围值)。 +软限位在位置显示中所示的工作坐标系中计算,因此当工作原点移动时它们保持不变。启用但没有自定义边界时,将使用工作表面边界。限位自动限制在轴范围内(0 到范围值)。 :::tip From afba25f2b0e0bb32c45582f0fde4dcbee132e3a4 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Sat, 17 Oct 2026 14:38:28 +0200 Subject: [PATCH 104/104] fix(ui): offer the commands in error messages as copy rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Error messages that tell the user what to run in a terminal — the Snap serial permission failures, for one — were rendered as plain text, so the only way to use a command was to type it by hand. The message is now split at its trailing command block: the prose stays in the status row or the banner that carries it, and each command becomes a CommandRow, the single-click copy row the permissions pre-flight page already used, now shared between both. The probe page in the setup wizard and the machine settings' error banner show them. A block is only taken when every line of it looks like something to type; whatever a message does not clearly hand over as a command is left exactly as written. Fixes #544 --- CHANGELOG.md | 4 + .../machine/general_preferences_page.py | 20 ++- .../machine/wizard_pages/permissions_page.py | 57 +------- .../ui_gtk/machine/wizard_pages/probe_page.py | 17 ++- rayforge/ui_gtk/shared/command_row.py | 122 ++++++++++++++++++ .../machine/test_general_preferences_page.py | 66 ++++++++++ tests/ui_gtk/machine/test_unified_wizard.py | 36 ++++++ tests/ui_gtk/test_command_row.py | 88 +++++++++++++ 8 files changed, 353 insertions(+), 57 deletions(-) create mode 100644 rayforge/ui_gtk/shared/command_row.py create mode 100644 tests/ui_gtk/machine/test_general_preferences_page.py create mode 100644 tests/ui_gtk/test_command_row.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b8252f80..0c935adbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -172,6 +172,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 cells and labels misaligned with the workpiece image until the next rebuild; toolpath outputs are stamped with the size their ops were generated at instead of the workpiece's live size (#568) +- Errors that hand you terminal commands no longer present them as a + wall of text: the explanation stays where it was and every command + becomes a one-click copy row, in the setup wizard's probe page and + under the machine settings' error banner (#544) ## 1.12.0 diff --git a/rayforge/ui_gtk/machine/general_preferences_page.py b/rayforge/ui_gtk/machine/general_preferences_page.py index 17445f83a..3a27a7126 100644 --- a/rayforge/ui_gtk/machine/general_preferences_page.py +++ b/rayforge/ui_gtk/machine/general_preferences_page.py @@ -9,6 +9,7 @@ from ...machine.models.machine import Machine from ...shared.units.system import UnitSystem from ..icons import get_icon +from ..shared.command_row import CommandRow, set_command_rows, split_commands from ..shared.pref_rows.acceleration_spin_row import AccelerationSpinRow from ..shared.pref_rows.speed_spin_row import SpeedSpinRow from ..shared.preferences_page import TrackedPreferencesPage @@ -40,12 +41,18 @@ def __init__(self, machine: Machine, **kwargs): # Error Banner Group error_group = Adw.PreferencesGroup() self.add(error_group) + self._error_group = error_group # Configuration Error Banner self.error_banner = Adw.Banner() self.error_banner.set_use_markup(True) self.error_banner.set_revealed(False) error_group.add(self.error_banner) + + # Terminal commands carried by the precheck error, one copy + # row each: they sit right under the banner that explains them. + self._command_rows: list[CommandRow] = [] + # Hide the group if the banner is not revealed to avoid extra spacing self.error_banner.connect( "notify::revealed", @@ -274,11 +281,14 @@ def _on_destroy(self, *args): def _update_error_state(self): """Shows or hides the error banner based on all possible errors.""" errors = [] + commands: list[str] = [] if self.machine.precheck_error: + prose, precheck_commands = split_commands( + self.machine.precheck_error + ) + commands.extend(precheck_commands) errors.append( - _("Configuration required: {error}").format( - error=self.machine.precheck_error - ) + _("Configuration required: {error}").format(error=prose) ) if self.machine.driver and self.machine.driver.state.error: errors.append( @@ -287,6 +297,10 @@ def _update_error_state(self): ) ) + self._command_rows = set_command_rows( + self._error_group, self._command_rows, commands + ) + if errors: full_error_msg = " \n".join(errors) self.error_banner.set_title(full_error_msg) diff --git a/rayforge/ui_gtk/machine/wizard_pages/permissions_page.py b/rayforge/ui_gtk/machine/wizard_pages/permissions_page.py index e6cc40cd3..6884c3e6e 100644 --- a/rayforge/ui_gtk/machine/wizard_pages/permissions_page.py +++ b/rayforge/ui_gtk/machine/wizard_pages/permissions_page.py @@ -17,7 +17,7 @@ from gettext import gettext as _ from typing import TYPE_CHECKING, Any -from gi.repository import Adw, GLib, Gtk +from gi.repository import Adw, Gtk from ....machine.device.profile import DeviceProfile from ....shared.util.permissions import ( @@ -25,61 +25,12 @@ check_permissions, ) from ...icons import get_icon +from ...shared.command_row import CommandRow from . import WizardPage, _makePreferencesGroup if TYPE_CHECKING: from ..unified_wizard import UnifiedWizard -_COPY_FEEDBACK_MS = 2000 - - -class _CommandRow(Adw.ActionRow): - """A terminal command that copies itself on a single click. - - The command text is the row title (monospace via Pango markup); - a copy icon sits in the suffix slot and flips to a check mark - briefly after copying. - """ - - def __init__(self, command: str, **kwargs: Any) -> None: - super().__init__(**kwargs) - self._command = command - self._feedback_source_id: int | None = None - - escaped = GLib.markup_escape_text(command, -1) - self.set_use_markup(True) - self.set_title(f'{escaped}') - self.set_activatable(True) - - self._icon = get_icon("copy-symbolic") - self._icon.set_valign(Gtk.Align.CENTER) - self.add_suffix(self._icon) - - self.connect("activated", self._on_activated) - self.connect("destroy", self._on_destroy) - - def _on_activated(self, _row: Adw.ActionRow) -> None: - display = self.get_display() - if display is None: - return - display.get_clipboard().set(self._command) - self._icon.set_from_icon_name("check-symbolic") - if self._feedback_source_id is not None: - GLib.source_remove(self._feedback_source_id) - self._feedback_source_id = GLib.timeout_add( - _COPY_FEEDBACK_MS, self._restore_icon - ) - - def _restore_icon(self) -> bool: - self._feedback_source_id = None - self._icon.set_from_icon_name("copy-symbolic") - return GLib.SOURCE_REMOVE - - def _on_destroy(self, _widget: Gtk.Widget) -> None: - if self._feedback_source_id is not None: - GLib.source_remove(self._feedback_source_id) - self._feedback_source_id = None - class PermissionsPage(WizardPage): step_number = 0 @@ -91,7 +42,7 @@ class PermissionsPage(WizardPage): def __init__(self, wizard: "UnifiedWizard", **kwargs: Any) -> None: self._sections: list[Gtk.Widget] = [] - self._command_rows: list[_CommandRow] = [] + self._command_rows: list[CommandRow] = [] super().__init__(wizard, **kwargs) def build_ui(self) -> None: @@ -173,7 +124,7 @@ def _build_issue_section(self, issue: PermissionIssue) -> Gtk.Widget: description=issue.summary, ) for command in issue.commands: - row = _CommandRow(command) + row = CommandRow(command) group.add(row) self._command_rows.append(row) box.append(group) diff --git a/rayforge/ui_gtk/machine/wizard_pages/probe_page.py b/rayforge/ui_gtk/machine/wizard_pages/probe_page.py index 226d54c0b..de419a995 100644 --- a/rayforge/ui_gtk/machine/wizard_pages/probe_page.py +++ b/rayforge/ui_gtk/machine/wizard_pages/probe_page.py @@ -17,6 +17,7 @@ Step 5 (AI lookup, if applicable) or Step 6 manual entry. """ +from collections.abc import Sequence from gettext import gettext as _ from typing import TYPE_CHECKING, Any @@ -29,6 +30,7 @@ from ....machine.driver.driver import DriverPrecheckError from ....shared.tasker import Task, task_mgr from ....shared.tasker.context import ExecutionContext +from ...shared.command_row import CommandRow, set_command_rows, split_commands from . import WizardPage, _makePreferencesGroup if TYPE_CHECKING: @@ -52,6 +54,9 @@ def __init__(self, wizard: "UnifiedWizard", **kwargs: Any) -> None: # so re-entering via Back does not auto-restart a probe. self._probed: bool = False self.probe_succeeded = Signal() + # Copy rows for the terminal commands an error message hands + # the user; rebuilt whenever the status changes. + self._command_rows: list[CommandRow] = [] super().__init__(wizard, **kwargs) def build_ui(self) -> None: @@ -107,6 +112,7 @@ def _reset_status(self) -> None: self.status_row.set_title(_("Status")) self.status_row.set_subtitle(_("Idle")) self.status_row.remove_css_class("error") + self._set_commands([]) self.spinner.stop() self.spinner.set_visible(False) self.probe_button.set_label(_("Probe Now")) @@ -172,6 +178,7 @@ def _update(): self.status_row.set_subtitle( _("Working area and speeds auto-detected.") ) + self._set_commands([]) self.spinner.stop() self.spinner.set_visible(False) self.probe_button.set_label(_("Probe Now")) @@ -182,14 +189,22 @@ def _update(): task_mgr.schedule_on_main_thread(_update) def _show_error(self, message: str) -> None: + prose, commands = split_commands(message) self.status_row.set_title(_("Error")) - self.status_row.set_subtitle(message) + self.status_row.set_subtitle(prose) self.status_row.add_css_class("error") + self._set_commands(commands) self.spinner.stop() self.spinner.set_visible(False) self.probe_button.set_label(_("Retry")) self.probe_button.set_sensitive(True) self.set_ready(True) + def _set_commands(self, commands: Sequence[str]) -> None: + """Show each terminal command the message carries as a copy row.""" + self._command_rows = set_command_rows( + self.group, self._command_rows, commands + ) + __all__ = ["ProbePage"] diff --git a/rayforge/ui_gtk/shared/command_row.py b/rayforge/ui_gtk/shared/command_row.py new file mode 100644 index 000000000..342ffc4c7 --- /dev/null +++ b/rayforge/ui_gtk/shared/command_row.py @@ -0,0 +1,122 @@ +"""Terminal commands that error messages hand the user. + +Two pieces live here: :class:`CommandRow`, which renders one command +as a single-click copy row, and :func:`split_commands`, which pulls +the command block out of a free-form error message so the prose can +stay where it was while the commands become rows. +""" + +import re +from collections.abc import Sequence +from typing import Any + +from gi.repository import Adw, GLib, Gtk + +from ..icons import get_icon + +_COPY_FEEDBACK_MS = 2000 + +# First word of a line that is meant to be typed: a program name, or +# something that starts a shell expression. +_COMMAND_HEAD = re.compile(r"[a-z0-9_$|#(]") + +# Sentence punctuation a line of prose ends on. +_PROSE_ENDING = (".", "?", "!") + + +def split_commands(message: str) -> tuple[str, list[str]]: + """Separate a message's prose from its trailing commands. + + Messages put the commands they tell the user to run after a blank + line. Such a block is only taken when *every* line of it looks like + something to type into a terminal: a line that starts like prose or + that ends a sentence leaves the message untouched, because a + message shown wrongly is worse than one without a copy button. + + Returns ``(prose, commands)``; ``prose`` is the whole message when + there is nothing to split off. + """ + head, separator, tail = message.rpartition("\n\n") + if not separator or not head: + return message, [] + lines = [line.strip() for line in tail.splitlines() if line.strip()] + if not lines: + return message, [] + for line in lines: + if line.endswith(_PROSE_ENDING): + return message, [] + first = line[0] + if not _COMMAND_HEAD.match(first): + return message, [] + return head, lines + + +class CommandRow(Adw.ActionRow): + """A terminal command that copies itself on a single click. + + The command text is the row title (monospace via Pango markup); + a copy icon sits in the suffix slot and flips to a check mark + briefly after copying. Use it wherever an error or a hint hands + the user something to type into a terminal, so the command is + never re-typed by hand. + """ + + def __init__(self, command: str, **kwargs: Any) -> None: + super().__init__(**kwargs) + self._command = command + self._feedback_source_id: int | None = None + + escaped = GLib.markup_escape_text(command, -1) + self.set_use_markup(True) + self.set_title(f'{escaped}') + self.set_activatable(True) + + self._icon = get_icon("copy-symbolic") + self._icon.set_valign(Gtk.Align.CENTER) + self.add_suffix(self._icon) + + self.connect("activated", self._on_activated) + self.connect("destroy", self._on_destroy) + + def _on_activated(self, _row: Adw.ActionRow) -> None: + display = self.get_display() + if display is None: + return + display.get_clipboard().set(self._command) + self._icon.set_from_icon_name("check-symbolic") + if self._feedback_source_id is not None: + GLib.source_remove(self._feedback_source_id) + self._feedback_source_id = GLib.timeout_add( + _COPY_FEEDBACK_MS, self._restore_icon + ) + + def _restore_icon(self) -> bool: + self._feedback_source_id = None + self._icon.set_from_icon_name("copy-symbolic") + return GLib.SOURCE_REMOVE + + def _on_destroy(self, _widget: Gtk.Widget) -> None: + if self._feedback_source_id is not None: + GLib.source_remove(self._feedback_source_id) + self._feedback_source_id = None + + +def set_command_rows( + group: Adw.PreferencesGroup, + rows: Sequence[CommandRow], + commands: Sequence[str], +) -> list[CommandRow]: + """Replace a group's copy rows with one row per command. + + Returns the new rows so the caller can hold on to them; the old + ones are removed from ``group`` and disposed of. + """ + for row in rows: + group.remove(row) + new_rows = [CommandRow(command) for command in commands] + for row in new_rows: + group.add(row) + return new_rows + + +__all__ = ["CommandRow", "set_command_rows", "split_commands"] diff --git a/tests/ui_gtk/machine/test_general_preferences_page.py b/tests/ui_gtk/machine/test_general_preferences_page.py new file mode 100644 index 000000000..26ada14db --- /dev/null +++ b/tests/ui_gtk/machine/test_general_preferences_page.py @@ -0,0 +1,66 @@ +"""Tests for the error display on the machine settings General page.""" + +import pytest + +from rayforge.machine.models.machine import Machine +from rayforge.ui_gtk.machine.general_preferences_page import ( + GeneralPreferencesPage, +) + +pytestmark = pytest.mark.ui + +SNAP_ERROR = ( + "Serial ports found, but none are accessible. Please ensure your " + "Snap has the 'serial-port' interface connected by running:\n" + "\n" + "sudo snap set system experimental.hotplug=true\n" + "sudo snap connect rayforge:serial-port" +) + + +def _make_page(context): + """A registered machine with its General settings page attached.""" + machine = Machine(context) + context.machine_mgr.add_machine(machine) + return machine, GeneralPreferencesPage(machine) + + +def test_precheck_error_commands_become_copy_rows(ui_context_initializer): + machine, page = _make_page(ui_context_initializer) + + machine.set_precheck_error(SNAP_ERROR) + machine.changed.send(machine) + + banner = page.error_banner.get_title() + assert banner.endswith("connected by running:") + assert "sudo snap" not in banner + assert [r._command for r in page._command_rows] == [ + "sudo snap set system experimental.hotplug=true", + "sudo snap connect rayforge:serial-port", + ] + + +def test_a_clear_precheck_takes_the_rows_with_it(ui_context_initializer): + machine, page = _make_page(ui_context_initializer) + + machine.set_precheck_error(SNAP_ERROR) + machine.changed.send(machine) + assert page._command_rows + + machine.set_precheck_error(None) + machine.changed.send(machine) + + assert page._command_rows == [] + assert not page.error_banner.get_revealed() + + +def test_an_error_without_commands_shows_only_the_banner( + ui_context_initializer, +): + machine, page = _make_page(ui_context_initializer) + + machine.set_precheck_error("No USB serial ports found.") + machine.changed.send(machine) + + assert page._command_rows == [] + assert "No USB serial ports found." in page.error_banner.get_title() diff --git a/tests/ui_gtk/machine/test_unified_wizard.py b/tests/ui_gtk/machine/test_unified_wizard.py index 43a0fedbd..6648992d0 100644 --- a/tests/ui_gtk/machine/test_unified_wizard.py +++ b/tests/ui_gtk/machine/test_unified_wizard.py @@ -238,6 +238,42 @@ def test_probe_page_footer_buttons(ui_context_initializer): assert [b.get_label() for b in wizard._footer_action_buttons] == ["Retry"] +@pytest.mark.ui +def test_probe_page_offers_error_commands_as_copy_rows( + ui_context_initializer, +): + """A precheck failure that hands out terminal commands must not be + a wall of text: the prose stays in the status row, each command + becomes a one-click copy row underneath it.""" + wizard = _make_wizard(ui_context_initializer) + wizard._navigate_to("probe") + page = wizard._get_page("probe") + assert isinstance(page, ProbePage) + + page._show_error( + "Serial ports found, but none are accessible. Please ensure " + "your Snap has the 'serial-port' interface connected by " + "running:\n" + "\n" + "sudo snap set system experimental.hotplug=true\n" + "sudo snap connect rayforge:serial-port" + ) + + assert page.status_row.get_subtitle() == ( + "Serial ports found, but none are accessible. Please ensure " + "your Snap has the 'serial-port' interface connected by " + "running:" + ) + assert [r._command for r in page._command_rows] == [ + "sudo snap set system experimental.hotplug=true", + "sudo snap connect rayforge:serial-port", + ] + + # Resetting the status takes the command rows with it. + page._reset_status() + assert page._command_rows == [] + + @pytest.mark.ui def test_wizard_step_order_declared(): from rayforge.ui_gtk.machine.unified_wizard import _STEP_ORDER diff --git a/tests/ui_gtk/test_command_row.py b/tests/ui_gtk/test_command_row.py new file mode 100644 index 000000000..8150ab6f4 --- /dev/null +++ b/tests/ui_gtk/test_command_row.py @@ -0,0 +1,88 @@ +"""Tests for rendering the terminal commands inside error messages.""" + +import pytest +from gi.repository import Adw + +from rayforge.ui_gtk.shared.command_row import ( + CommandRow, + set_command_rows, + split_commands, +) + +pytestmark = pytest.mark.ui + +# The message the serial transport raises when a Snap cannot reach the +# ports, exactly as written there: prose, a blank line, then the +# commands. +SNAP_ERROR = ( + "Serial ports found, but none are accessible. Please ensure your " + "Snap has the 'serial-port' interface connected by running:\n" + "\n" + "sudo snap set system experimental.hotplug=true\n" + "sudo snap connect rayforge:serial-port" +) + + +def test_split_commands_takes_the_trailing_block(): + prose, commands = split_commands(SNAP_ERROR) + + assert prose == ( + "Serial ports found, but none are accessible. Please ensure " + "your Snap has the 'serial-port' interface connected by running:" + ) + assert commands == [ + "sudo snap set system experimental.hotplug=true", + "sudo snap connect rayforge:serial-port", + ] + + +def test_a_message_without_a_blank_line_is_left_alone(): + prose, commands = split_commands("No USB serial ports found.") + + assert prose == "No USB serial ports found." + assert commands == [] + + +def test_a_sentence_after_a_blank_line_stays_in_the_message(): + message = ( + "Could not access any serial ports. On Linux, ensure your " + "user is in the 'dialout' group.\n" + "\n" + "Then log out and back in." + ) + + prose, commands = split_commands(message) + + assert prose == message + assert commands == [] + + +def test_a_capitalised_line_is_left_alone(): + message = "Something went wrong.\n\nThen restart Rayforge." + + prose, commands = split_commands(message) + + assert prose == message + assert commands == [] + + +def test_command_row_keeps_the_exact_text(ui_context_initializer): + row = CommandRow("sudo snap connect rayforge:serial-port") + + assert row._command == "sudo snap connect rayforge:serial-port" + assert "monospace" in row.get_title() + + +def test_set_command_rows_replaces_the_previous_rows(ui_context_initializer): + group = Adw.PreferencesGroup() + rows = set_command_rows(group, [], ["sudo a", "sudo b"]) + assert [r._command for r in rows] == ["sudo a", "sudo b"] + + previous = rows[0] + rows = set_command_rows(group, rows, ["sudo c"]) + + assert previous.get_parent() is None + assert [r._command for r in rows] == ["sudo c"] + + rows = set_command_rows(group, rows, []) + assert rows == []