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/build-appimage.yml b/.github/workflows/build-appimage.yml
index 713404f36..5f6814c79 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
@@ -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/.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/.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 f0b31c594..0c6593a47 100644
--- a/.github/workflows/pixi-dependabot.yml
+++ b/.github/workflows/pixi-dependabot.yml
@@ -17,16 +17,11 @@ 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
- - 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:
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/.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
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/CHANGELOG.md b/CHANGELOG.md
index e29ae223a..0c935adbd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,10 +5,50 @@ 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
-
-### Added
-
+## 1.13.0-beta1
+
+### 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
+ 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)
+- 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
+ 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
@@ -26,11 +66,45 @@ 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
+ 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
+- Sketcher: Polygon tool (G+X) for regular polygons and stars, made of
+ plain points and lines that constraints keep regular (#522)
+- 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
+- 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
@@ -39,10 +113,41 @@ 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
+- 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
+- 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)
+- 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
@@ -60,6 +165,17 @@ 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)
+- 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/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/README.md b/README.md
index 62e0b942a..7f3ed43f0 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
[](https://launchpad.net/~knipknap/+archive/ubuntu/rayforge)
[](https://opensource.org/licenses/MIT)
-[](https://snapcraft.io/rayforge)
+
@@ -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
@@ -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/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/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.'
+ ),
},
)
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..276e47ad8 100644
--- a/data/org.rayforge.rayforge.metainfo.xml
+++ b/data/org.rayforge.rayforge.metainfo.xml
@@ -52,6 +52,7 @@
image/bmpimage/jpegimage/svg+xml
+ image/svg+xml-compressedimage/vnd.dxf
@@ -76,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/debian/requirements-bundle.txt b/debian/requirements-bundle.txt
index e9fe1a1ef..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.61.0
-ruida-pa==0.21.2
+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/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/pixi.lock b/pixi.lock
index dfd1f6f29..467bf6e67 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,73 +227,73 @@ 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
- 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/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/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/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
- pypi: https://files.pythonhosted.org/packages/17/fe/77f4a24264e728fd95542e53f026a91249b51d1611087b8c82d3a033ea97/asyncudp-0.11.0-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/18/42/a945f65cc61c739ec80f4112c4b78ed1791f25d33f45f19389c9c9e247e2/pypdf-6.20.0-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/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/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
- 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/dd/e9/fe7809075e9b29f9d938950eefe7a3da84d0f69be540d6dc1aa5a25ef02e/raygeo-1.61.0.tar.gz
+ - 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
@@ -471,61 +466,61 @@ 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
- 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/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/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
- 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/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/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/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
- - 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/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/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
- 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/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/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
@@ -1177,78 +1167,78 @@ 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
- 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/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/4b/5c/7bb93dd4c4c755c5bf99e9ffadeb4ec8a6477fd229994fdcbc74e25a76b7/raygeo-1.61.0-cp311-abi3-macosx_11_0_arm64.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
@@ -9647,26 +9494,26 @@ packages:
name: rayforge
requires_dist:
- raydriver==0.3.0
- - raygeo==1.61.0
- - aiohttp==3.14.3
+ - raygeo==1.66.0
+ - 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
- - ruida-pa==0.21.2
+ - ruida-pa==0.22.0
- scipy==1.18.1
- semver==3.1.0
- svgelements==1.9.6
@@ -9684,11 +9531,36 @@ packages:
name: distlib
version: 0.4.3
sha256: 4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b
-- 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/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
+ 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/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl
name: pytest-asyncio
version: 1.4.0
@@ -9703,6 +9575,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
@@ -9714,60 +9591,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
- 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
+- 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:
- - bokeh>=3.9
- - pyserial>=3.5
- - textual>=0.32
- - rpyc>=6.0
+ - 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,69 +9632,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/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
+- pypi: https://files.pythonhosted.org/packages/18/42/a945f65cc61c739ec80f4112c4b78ed1791f25d33f45f19389c9c9e247e2/pypdf-6.20.0-py3-none-any.whl
name: pypdf
- version: 6.19.0
- sha256: 7e5d6e730e7dae87d560a2cee218b852f6498c8be61966f3cd02ead971e48d14
+ 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'
@@ -9866,6 +9663,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'
@@ -9874,6 +9672,50 @@ packages:
- arabic-reshaper ; extra == 'rtl-text'
- python-bidi ; extra == 'rtl-text'
requires_python: '>=3.9'
+- 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
@@ -9897,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
@@ -9908,55 +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/4b/5c/7bb93dd4c4c755c5bf99e9ffadeb4ec8a6477fd229994fdcbc74e25a76b7/raygeo-1.61.0-cp311-abi3-macosx_11_0_arm64.whl
- name: raygeo
- version: 1.61.0
- sha256: 69f0683f4405c7fecef3cccc7ff07f5a0375a3b587a4893b609c1ee0cb8de67f
+- 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:
- - 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
- sha256: 8e124f974786f831d6043728e38296969d3579db8896fe004682f5758e613581
+ - 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/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
- 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/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,70 @@ 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
+ - 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/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
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 +9909,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 +9923,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 +9941,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 +9993,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 +10021,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 +10062,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 +10103,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 +10123,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
+- 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:
- - 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'
+ - 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 +10163,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 +10207,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 +10224,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 +10243,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 +10274,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 +10295,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 +10313,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,39 +10385,44 @@ 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
+- 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:
- - 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'
+ - 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/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/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:
- - 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'
+ - 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
@@ -10675,63 +10437,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:
- 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/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 97974da42..c06d8149f 100644
--- a/pixi.toml
+++ b/pixi.toml
@@ -19,38 +19,38 @@ 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]
-raygeo = "==1.61.0"
+raygeo = "==1.66.0"
raydriver = "==0.3.0"
rayforge = { editable = true, path = "." }
-ruida-pa = "==0.21.2"
-aiohttp = "==3.14.3"
+ruida-pa = "==0.22.0"
+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/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/adjust_dialog.py b/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/adjust_dialog.py
new file mode 100644
index 000000000..43b13fefa
--- /dev/null
+++ b/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/adjust_dialog.py
@@ -0,0 +1,212 @@
+"""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, 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
+from rayforge.ui_gtk.shared.slider import create_slider_row
+
+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
+
+#: The dialog width, matching the step settings dialog.
+DEFAULT_WIDTH = 600
+
+
+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._preview_timer_id: int | None = None
+
+ self.set_title(_("Adjust Image"))
+ self.set_default_size(DEFAULT_WIDTH, -1)
+
+ 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))
+ 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 _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,
+ on_value_changed=self._schedule_preview,
+ format_suffix=format_suffix,
+ )
+
+ def _build_page(self) -> Adw.PreferencesPage:
+ 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.contrast_scale = self._slider_row(
+ _("Contrast"),
+ _("Spread or compress the tones around mid-gray"),
+ -100,
+ 100,
+ 1,
+ 0,
+ format_suffix="%",
+ )
+ self.gamma_row, self.gamma_scale = self._slider_row(
+ _("Gamma"),
+ _("Values above 1 lighten the midtones, below 1 darken them"),
+ 0.2,
+ 5.0,
+ 0.05,
+ 2,
+ )
+ 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"),
+ _("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_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_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()
+ 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 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]
+ 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
new file mode 100644
index 000000000..444afad4e
--- /dev/null
+++ b/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/commands.py
@@ -0,0 +1,99 @@
+from collections.abc import Mapping, 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
+
+
+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
new file mode 100644
index 000000000..fa162f1be
--- /dev/null
+++ b/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/frontend.py
@@ -0,0 +1,82 @@
+"""
+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 typing import TYPE_CHECKING
+
+from gi.repository import Gio
+
+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
+
+if TYPE_CHECKING:
+ from rayforge.ui_gtk.actions import ActionManager
+
+ADDON_NAME = "image_editing"
+INVERT_ACTION = "invert-image"
+ADJUST_ACTION = "adjust-image"
+
+
+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."""
+ _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):
+ window = action_registry.window
+ toggle_image_edit(
+ window.doc_editor.history_manager,
+ window.surface.get_selected_workpieces(),
+ INVERT,
+ _("Invert Image"),
+ )
+
+ action.connect("activate", on_activate)
+ action_registry.register(
+ action_name=INVERT_ACTION,
+ action=action,
+ addon_name=ADDON_NAME,
+ label=_("Invert Image"),
+ 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
new file mode 100644
index 000000000..97eac4c74
--- /dev/null
+++ b/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/ops.py
@@ -0,0 +1,150 @@
+from collections.abc import Mapping
+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."""
+
+ 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)
+
+
+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, ToneOp, SharpenOp)
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..59ab13cc2
--- /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: 23
+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..b21a0455f
--- /dev/null
+++ b/rayforge/builtin_addons/rayforge-addon-image-editing/tests/conftest.py
@@ -0,0 +1,19 @@
+"""
+Pytest configuration for image_editing builtin addon tests.
+"""
+
+import pytest
+from image_editing.ops import ALL_OPS
+
+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."""
+ 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
new file mode 100644
index 000000000..82f6a6281
--- /dev/null
+++ b/rayforge/builtin_addons/rayforge-addon-image-editing/tests/test_commands.py
@@ -0,0 +1,112 @@
+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
+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()
+
+
+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
new file mode 100644
index 000000000..64fa84c6a
--- /dev/null
+++ b/rayforge/builtin_addons/rayforge-addon-image-editing/tests/test_ops.py
@@ -0,0 +1,160 @@
+import numpy as np
+import pyvips
+from image_editing.ops import (
+ INVERT,
+ InvertOp,
+ SharpenOp,
+ ToneOp,
+ tone_curve,
+)
+
+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]
+
+
+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
new file mode 100644
index 000000000..8df1702a4
--- /dev/null
+++ b/rayforge/builtin_addons/rayforge-addon-image-editing/tests/test_raster_equivalence.py
@@ -0,0 +1,68 @@
+"""
+The Invert edit replaces the Engrave step's old Invert setting; these
+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, ToneOp
+
+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)
+
+
+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..46a5fc0af
--- /dev/null
+++ b/rayforge/builtin_addons/rayforge-addon-image-editing/tests/ui_gtk/test_adjust_dialog.py
@@ -0,0 +1,112 @@
+# flake8: noqa: E402
+"""UI tests for the Adjust Image dialog."""
+
+import gi
+import pytest
+import pyvips
+
+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_is_not_modal(dialog):
+ assert not dialog.get_modal()
+
+
+@pytest.mark.ui
+def test_preview_shows_without_undo(dialog, workpieces):
+ dialog.contrast_scale.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_scale.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_scale.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,)
+
+
+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_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_scale.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/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/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/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/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..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,
@@ -27,7 +28,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 +81,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 +135,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",
@@ -180,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"),
@@ -286,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
@@ -305,6 +347,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 +439,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,
@@ -471,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,
@@ -481,15 +532,28 @@ 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
+ 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
@@ -510,6 +574,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
@@ -529,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)
)
@@ -600,6 +669,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 +704,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",
@@ -756,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,
@@ -771,6 +848,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:
@@ -785,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 330381376..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
@@ -19,6 +19,9 @@
"depth_mode",
"threshold",
"dither_algorithm",
+ "dither_serpentine",
+ "halftone_cell_mm",
+ "halftone_angle",
"scan_angle",
"cross_hatch",
"scan_mode",
@@ -26,7 +29,6 @@
"sample_interval_mm",
"dot_width_correction_mm",
"bidir_x_offset_mm",
- "invert",
"num_depth_levels",
"z_step_down",
"angle_increment",
@@ -76,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):
@@ -117,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/locale/de/LC_MESSAGES/laser_essentials.po b/rayforge/builtin_addons/rayforge-addon-laser/locale/de/LC_MESSAGES/laser_essentials.po
index c0a678a26..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"
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..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"
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..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"
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..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"
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..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 "गहराई स्तरों की संख्या"
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..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 ""
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..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"
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..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 "Кількість рівнів глибини"
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..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 "深度层级数"
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-laser/tests/steps/test_raster_step.py b/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_raster_step.py
index 7875f4c7b..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
@@ -10,12 +10,17 @@
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
from rayforge.image.dither import DitherAlgorithm
from rayforge.pipeline.transformer import OpsTransformer
from rayforge.pipeline.transformer.registry import transformer_registry
+LEGACY_INVERT = ImageEditStep("invert")
+
@pytest.fixture
def mock_context():
@@ -203,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
@@ -543,3 +548,198 @@ 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)
+
+
+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)
+ 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
+
+ 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 c5c44e9bd..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
@@ -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 (
@@ -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,82 @@ 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
+):
+ """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"
+
+
+@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)
@@ -607,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/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/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/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/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..e0a36a2a5
--- /dev/null
+++ b/rayforge/builtin_addons/rayforge-addon-post/post_processors/transformers/perforation_transformer.py
@@ -0,0 +1,138 @@
+from __future__ import annotations
+
+from collections.abc import Sequence
+from gettext import gettext as _
+from typing import TYPE_CHECKING, Any
+
+from raygeo.geo import Matrix
+from raygeo.ops.transform.tabs import TabsSpec
+
+from rayforge.core.workpiece import WorkPiece
+from rayforge.pipeline.transformer.base import OpsTransformer
+
+if TYPE_CHECKING:
+ from raygeo.geo import Geometry
+
+
+MIN_LENGTH = 0.01
+EPSILON = 1e-4
+
+
+class PerforationTransformer(OpsTransformer):
+ """
+ Turns cut paths into a dashed cut: the laser fires for
+ ``cut_length`` and is off for ``skip_length``, repeated along
+ every contour of the workpiece.
+
+ The gaps are placed in millimetres along each contour of the
+ workpiece's boundaries, starting with a full cut at the start of
+ every contour, and handed to the tabs gap machinery, which cuts
+ them out of the nearest toolpath.
+ """
+
+ DEFAULT_CUT_LENGTH = 2.0
+ DEFAULT_SKIP_LENGTH = 1.0
+
+ def __init__(
+ self,
+ enabled: bool = True,
+ cut_length: float = DEFAULT_CUT_LENGTH,
+ skip_length: float = DEFAULT_SKIP_LENGTH,
+ ):
+ super().__init__(enabled=enabled)
+ self._cut_length = max(MIN_LENGTH, cut_length)
+ self._skip_length = max(MIN_LENGTH, skip_length)
+
+ @property
+ def cut_length(self) -> float:
+ return self._cut_length
+
+ @cut_length.setter
+ def cut_length(self, value: float) -> None:
+ self._cut_length = max(MIN_LENGTH, value)
+ self.changed.send(self)
+
+ @property
+ def skip_length(self) -> float:
+ return self._skip_length
+
+ @skip_length.setter
+ def skip_length(self, value: float) -> None:
+ self._skip_length = max(MIN_LENGTH, value)
+ self.changed.send(self)
+
+ @property
+ def label(self) -> str:
+ return _("Perforation")
+
+ @property
+ def description(self) -> str:
+ return _(
+ "Cuts in dashes: the laser fires for the cut length and "
+ "skips the skip length"
+ )
+
+ def _gap_centres(self, length: float) -> list[float]:
+ period = self._cut_length + self._skip_length
+ half_gap = self._skip_length / 2.0
+ centres: list[float] = []
+ centre = self._cut_length + half_gap
+ while centre + half_gap <= length + EPSILON:
+ centres.append(centre)
+ centre += period
+ return centres
+
+ def _clips_for_contour(
+ self, contour: Geometry
+ ) -> list[tuple[float, float, float]]:
+ centres = self._gap_centres(contour.distance())
+ if not centres:
+ return []
+ positions = contour.get_positions_at_distances(centres)
+ return [
+ (point[0], point[1], self._skip_length)
+ for _seg, _t, point in positions
+ ]
+
+ def _generate_clips(
+ self, workpiece: WorkPiece
+ ) -> list[tuple[float, float, float]]:
+ boundaries = workpiece.boundaries
+ if boundaries is None or boundaries.is_empty():
+ return []
+ width, height = workpiece.size
+ scaled = boundaries.copy()
+ if width > 0 and height > 0:
+ scaled.transform(Matrix.scale(width, height))
+ clips: list[tuple[float, float, float]] = []
+ for contour in scaled.split_into_contours():
+ clips.extend(self._clips_for_contour(contour))
+ return clips
+
+ def to_spec(
+ self,
+ workpiece: WorkPiece | None,
+ stock_geometries: Sequence[Geometry] | None,
+ settings: dict[str, Any] | None,
+ ) -> TabsSpec:
+ clips = self._generate_clips(workpiece) if workpiece else []
+ return TabsSpec(tab_power=0.0, original_power=1.0, clips=clips)
+
+ def to_dict(self) -> dict[str, Any]:
+ data = super().to_dict()
+ data["cut_length"] = self._cut_length
+ data["skip_length"] = self._skip_length
+ return data
+
+ @classmethod
+ def from_dict(cls, data: dict[str, Any]) -> PerforationTransformer:
+ if data.get("name") != cls.__name__:
+ raise ValueError(
+ f"Mismatched transformer name: expected {cls.__name__},"
+ f" got {data.get('name')}"
+ )
+ return cls(
+ enabled=data.get("enabled", True),
+ cut_length=data.get("cut_length", cls.DEFAULT_CUT_LENGTH),
+ skip_length=data.get("skip_length", cls.DEFAULT_SKIP_LENGTH),
+ )
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..5bd1135be
--- /dev/null
+++ b/rayforge/builtin_addons/rayforge-addon-post/post_processors/widgets/perforation_group.py
@@ -0,0 +1,70 @@
+from gettext import gettext as _
+from typing import TYPE_CHECKING
+
+from rayforge.shared.util.glib import DebounceMixin
+from rayforge.ui_gtk.doceditor.post_processor.groups import (
+ ExpanderHost,
+ TransformerSettingsGroup,
+)
+from rayforge.ui_gtk.shared.pref_rows import LengthSpinRow
+
+from ..transformers import PerforationTransformer
+from ..transformers.perforation_transformer import MIN_LENGTH
+
+if TYPE_CHECKING:
+ from rayforge.core.step import Step
+
+
+class PerforationSettingsGroup(DebounceMixin, TransformerSettingsGroup):
+ """UI for configuring the PerforationTransformer."""
+
+ def __init__(
+ self,
+ title: str,
+ transformer: PerforationTransformer,
+ page: ExpanderHost,
+ *,
+ step: "Step | None" = None,
+ **kwargs,
+ ):
+ super().__init__(title, transformer, page, step=step, **kwargs)
+
+ self.cut_row = LengthSpinRow(
+ _("Cut Length"),
+ _("Distance the laser fires before each gap"),
+ lower=MIN_LENGTH,
+ upper=1000.0,
+ value_in_base=transformer.cut_length,
+ )
+ self.cut_row.value_changed.connect(
+ lambda r: self._debounce(self._on_cut_changed, r)
+ )
+ self.add(self.cut_row)
+
+ self.skip_row = LengthSpinRow(
+ _("Skip Length"),
+ _("Distance travelled with the laser off between cuts"),
+ lower=MIN_LENGTH,
+ upper=1000.0,
+ value_in_base=transformer.skip_length,
+ )
+ self.skip_row.value_changed.connect(
+ lambda r: self._debounce(self._on_skip_changed, r)
+ )
+ self.add(self.skip_row)
+
+ def _on_cut_changed(self, row: LengthSpinRow) -> None:
+ self.param_changed.send(
+ self,
+ key="cut_length",
+ value=row.get_value_in_base_units(),
+ name=_("Change perforation cut length"),
+ )
+
+ def _on_skip_changed(self, row: LengthSpinRow) -> None:
+ self.param_changed.send(
+ self,
+ key="skip_length",
+ value=row.get_value_in_base_units(),
+ name=_("Change perforation skip length"),
+ )
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_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/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..15191561c
--- /dev/null
+++ b/rayforge/builtin_addons/rayforge-addon-post/tests/transformers/test_perforation_transformer.py
@@ -0,0 +1,197 @@
+from unittest.mock import MagicMock
+
+import pytest
+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:
+ 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
+
+
+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
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
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..3ab72be14
--- /dev/null
+++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/commands/polygon.py
@@ -0,0 +1,357 @@
+from __future__ import annotations
+
+import itertools
+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:
+ corners = set(self.corner_ids)
+ 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 = []
+
+ 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)
+ first.x, first.y = _at_radius(center, 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 _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
+ return (
+ center.x + dx / length * radius,
+ 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,
+ end_pid: EntityID | None = None,
+ 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.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
+ 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:
+ 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)
+
+ @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 _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, first: GeoPoint
+ ) -> tuple[list[Point], list[Entity], list[Constraint]]:
+ temp_ids = itertools.count(-2, -1)
+ vertices = polygon_vertices(
+ (center.x, center.y), first, 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]]
+ 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)
+ first = self._first_corner(center)
+ except IndexError:
+ return
+ 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, first)
+ 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..6dcf62d91
--- /dev/null
+++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/tools/polygon_tool.py
@@ -0,0 +1,312 @@
+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(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:
+ 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(end_pid=self._snapped_corner_id())
+
+ 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,
+ end_pid: int | 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,
+ end_pid=end_pid,
+ 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..7f0947313
--- /dev/null
+++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/commands/test_polygon_cmd.py
@@ -0,0 +1,252 @@
+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_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)
+
+ 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_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
+ 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..34c530ce8
--- /dev/null
+++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/ui_gtk/tools/test_polygon_tool.py
@@ -0,0 +1,165 @@
+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_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)
+ 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/camera/controller.py b/rayforge/camera/controller.py
index ee2aa0bca..42bd5c5ff 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,10 @@ 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.
+ # 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
# at most one capture thread can ever own the device at a time
@@ -103,7 +119,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.
@@ -119,9 +135,8 @@ def _on_config_changed(self, sender):
"""Reacts to changes in the data model."""
if self._disposed:
return
- self._settings_dirty = True
- with self._frame_lock:
- self._accumulator = None # Reset smoothing if settings change
+ if not self.config.outside_view_enabled:
+ self._outside_cache.clear()
new_source_key = self._source_key()
source_changed = new_source_key != self._last_source_key
self._last_source_key = new_source_key
@@ -136,6 +151,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]:
"""
@@ -346,6 +369,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 +501,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:
@@ -545,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:
@@ -754,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/rayforge/camera/models/camera.py b/rayforge/camera/models/camera.py
index 10f961868..601dbff2b 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,79 @@ 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)
+
+ @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)
+
+ @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)
+
@property
def resolution(self) -> tuple[int, int] | None:
return self._resolution
@@ -656,6 +738,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 +810,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 +856,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/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/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/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..5d20b6758 100644
--- a/rayforge/core/hooks.py
+++ b/rayforge/core/hooks.py
@@ -4,13 +4,33 @@
hookimpl = pluggy.HookimplMarker("rayforge")
MINIMUM_API_VERSION = 16
-PLUGIN_API_VERSION = 21
+PLUGIN_API_VERSION = 23
"""
API Changelog
=============
+Version 23
+----------
+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 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
@@ -233,6 +253,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_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/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..2b5b5ffe6
--- /dev/null
+++ b/rayforge/core/image_edit/base.py
@@ -0,0 +1,64 @@
+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. Its
+ ``xres``/``yres`` give the resolution in pixels per mm, for
+ edits with physical sizes.
+ """
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/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/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/source_asset.py b/rayforge/core/source_asset.py
index 1160374f9..515c035df 100644
--- a/rayforge/core/source_asset.py
+++ b/rayforge/core/source_asset.py
@@ -1,9 +1,9 @@
from __future__ import annotations
import base64
+import hashlib
import logging
import uuid
-from collections import OrderedDict
from dataclasses import dataclass, field
from gettext import gettext as _
from pathlib import Path
@@ -12,10 +12,15 @@
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__)
+SOURCE_FILE_SHA256_KEY = "source_file_sha256"
+SOURCE_FILE_SIZE_KEY = "source_file_size"
+
if TYPE_CHECKING:
from ..image.base_renderer import Renderer
@@ -48,13 +53,18 @@ 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)
_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
@@ -72,16 +82,52 @@ 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()
+ 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."""
@@ -165,6 +211,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
@@ -190,6 +237,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}
@@ -224,6 +272,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/step.py b/rayforge/core/step.py
index d75e5d07c..0f5bb1e3a 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,
@@ -24,6 +25,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
@@ -93,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 = (
@@ -547,6 +549,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 = (
@@ -563,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
@@ -600,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",
@@ -666,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 = (
@@ -801,11 +821,39 @@ 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:
- 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/core/workpiece.py b/rayforge/core/workpiece.py
index 2ecedf12d..9fc954308 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
@@ -26,8 +26,11 @@
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
from .tab import Tab
@@ -46,6 +49,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."""
@@ -87,7 +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: LruCache[
+ tuple[int, int, tuple[float, float]], pyvips.Image
+ ] = new_image_cache()
# Transient attributes for deserialized instances in subprocesses
self._data: bytes | None = None
@@ -219,6 +247,28 @@ 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:
+ """
+ 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]:
"""
@@ -243,6 +293,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
@@ -575,6 +626,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()
@@ -804,14 +857,36 @@ 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 central hub for rendering a vips image for this workpiece.
+ 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
+ size = self.size
+ key = (width, height, size)
+ processed = self._processed_cache.get(key)
+ if processed is None:
+ processed = apply_image_edits(
+ _with_resolution(image, size), self._image_edits
+ )
+ self._processed_cache.put(key, processed)
+ return processed
+
+ def _get_vips_image(self, width: int, height: int) -> pyvips.Image | None:
+ """
+ Renders the unedited vips image for this workpiece.
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()
@@ -853,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
@@ -887,6 +962,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,
@@ -931,6 +1007,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",
@@ -971,6 +1048,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"
@@ -1077,7 +1157,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)
@@ -1168,7 +1248,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/doceditor/editor.py b/rayforge/doceditor/editor.py
index 16e8c1b32..41fc8405a 100644
--- a/rayforge/doceditor/editor.py
+++ b/rayforge/doceditor/editor.py
@@ -26,6 +26,8 @@
from .group_cmd import GroupCmd
from .layer_cmd import LayerCmd
from .layout_cmd import LayoutCmd
+from .path_cleanup_cmd import PathCleanupCmd
+from .reload_cmd import ReloadCmd
from .split_cmd import SplitCmd
from .step_cmd import StepCmd
from .stock_cmd import StockCmd
@@ -151,6 +153,8 @@ 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.reload = ReloadCmd(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..1ca509333
--- /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.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,
+ Geometry.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,
+ Geometry.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, Geometry.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 geo.matches(other, tolerance_mm)
+ for _wp, other in kept
+ ):
+ duplicates.append(workpiece)
+ else:
+ kept.append((workpiece, geo))
+ return duplicates
diff --git a/rayforge/doceditor/reload_cmd.py b/rayforge/doceditor/reload_cmd.py
new file mode 100644
index 000000000..4423d3557
--- /dev/null
+++ b/rayforge/doceditor/reload_cmd.py
@@ -0,0 +1,665 @@
+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
+MAX_LISTED_TABS = 5
+
+_SOURCE_DATA_FIELDS = (
+ "original_data",
+ "base_render_data",
+ "thumbnail_data",
+ "renderer",
+ "metadata",
+ "width_px",
+ "height_px",
+ "width_mm",
+ "height_mm",
+)
+
+
+@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."""
+
+ source_name: str
+ updated: int = 0
+ added: int = 0
+ removed: int = 0
+ skipped: 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(
+ 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.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."""
+
+ 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 new_values}
+
+ 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 _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)
+ 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 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:
+ """
+ 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.
+ """
+ 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 = path.read_bytes()
+ plan = self._plan(asset, path, data, report)
+ except Exception as e:
+ logger.exception(f"Reload of {path} 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,
+ path: Path,
+ 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
+ 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,
+ path: Path,
+ 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, path, data, workpieces, plan, report
+ )
+ new_source = new_source or _source_of(result)
+
+ if new_source is None:
+ result = self._run(importer_cls, data, path, 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
+ 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,
+ report: ReloadReport,
+ ) -> ImportResult:
+ segment = workpieces[0].source_segment
+ assert segment is not None
+ spec = segment.vectorization_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):
+ 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)
+ 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,
+ natural_size=fresh.natural_size,
+ tabs=tabs,
+ )
+
+ @staticmethod
+ def _remap_tabs(
+ wp: WorkPiece, fresh: WorkPiece, matrix: Matrix
+ ) -> 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 and reported with their former world position.
+ """
+ if not wp.tabs:
+ return [], []
+ old_boundaries = wp.boundaries
+ new_boundaries = fresh.boundaries
+ world = wp.get_world_transform()
+ if old_boundaries is None or new_boundaries is None:
+ 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: list[Tab] = []
+ dropped: list[DroppedTab] = []
+ for tab in wp.tabs:
+ point = old_geo.get_point_at(tab.segment_index, tab.pos)
+ 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)),
+ )
+ )
+ continue
+ dropped.append(
+ _dropped_tab(wp, old_boundaries, tab, world, distance)
+ )
+ return kept, dropped
+
+ 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/rayforge/doceditor/split_cmd.py b/rayforge/doceditor/split_cmd.py
index 12f08883e..884df1af0 100644
--- a/rayforge/doceditor/split_cmd.py
+++ b/rayforge/doceditor/split_cmd.py
@@ -49,6 +49,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 workpiece.boundaries.split_drawn_contours()
+
+
class SplitCmd:
"""Handles splitting of document items."""
@@ -59,6 +72,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 +83,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 +96,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/rayforge/image/__init__.py b/rayforge/image/__init__.py
index def13cfd8..aa7224975 100644
--- a/rayforge/image/__init__.py
+++ b/rayforge/image/__init__.py
@@ -13,11 +13,15 @@
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
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
@@ -28,6 +32,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,
@@ -224,8 +229,10 @@ def import_file(
_RENDERERS = [
+ BITMAP_RENDERER,
BMP_RENDERER,
DXF_RENDERER,
+ HPGL_RENDERER,
LIGHTBURN_RENDERER,
PROCEDURAL_RENDERER,
JPG_RENDERER,
@@ -247,10 +254,14 @@ def get_renderer_for_asset(asset_type: str) -> Renderer | None:
__all__ = [
+ "AiImporter",
"BmpImporter",
"DxfImporter",
+ "EpsImporter",
"GeometryDxfExporter",
"GeometrySvgExporter",
+ "GifImporter",
+ "HpglImporter",
"ImportManifest",
"ImportPayload",
"ImportResult",
@@ -263,6 +274,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/image/dither.py b/rayforge/image/dither.py
index 1a0d64881..63cadf37e 100644
--- a/rayforge/image/dither.py
+++ b/rayforge/image/dither.py
@@ -7,27 +7,70 @@
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,
)
+DEFAULT_HALFTONE_CELL_MM = 0.5
+DEFAULT_HALFTONE_ANGLE = 45.0
+
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 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",
+}
+
BAYER_MATRICES = {
DitherAlgorithm.BAYER2: np.array([[0, 2], [3, 1]], dtype=np.float32),
@@ -56,6 +99,11 @@ def grayscale_to_dithered_array(
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 +113,38 @@ 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_dither(
+ grayscale, halftone_cell_mm, halftone_angle, pixels_per_mm
+ )
+ if dither_algorithm == DitherAlgorithm.NEWSPRINT:
+ 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)
def surface_to_dithered_array(
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/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/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..5a7b82609
--- /dev/null
+++ b/rayforge/image/postscript/importer.py
@@ -0,0 +1,143 @@
+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, 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 _(
+ "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:
+ 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())
+ 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()
+ 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,
+ 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/image/svg/importer.py b/rayforge/image/svg/importer.py
index 85e631cf4..b8c4d2ebd 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
@@ -35,8 +38,8 @@ class SvgImporter(Importer):
"""
label = "SVG files"
- mime_types = ("image/svg+xml",)
- extensions = (".svg",)
+ mime_types = ("image/svg+xml", "image/svg+xml-compressed")
+ 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/rayforge/locale/de/LC_MESSAGES/rayforge.po b/rayforge/locale/de/LC_MESSAGES/rayforge.po
index c24892115..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-06 22:41+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"
@@ -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"
@@ -8616,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 ""
@@ -8728,10 +8961,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 +9100,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 +9243,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 +10518,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 +10597,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 +10690,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 +10748,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 "Die gzip-Daten dieser SVGZ-Datei konnten nicht dekomprimiert werden."
+
+#: 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."
@@ -10459,6 +10861,34 @@ msgstr "Fehler beim Scannen der JPEG-Datei: {}"
msgid "Floyd Steinberg"
msgstr "Floyd Steinberg"
+#: rayforge/image/dither.py
+msgid "Atkinson"
+msgstr "Atkinson"
+
+#: rayforge/image/dither.py
+msgid "Stucki"
+msgstr "Stucki"
+
+#: rayforge/image/dither.py
+msgid "Jarvis, Judice & Ninke"
+msgstr "Jarvis, Judice & Ninke"
+
+#: rayforge/image/dither.py
+msgid "Sierra"
+msgstr "Sierra"
+
+#: rayforge/image/dither.py
+msgid "Sierra 2-Row"
+msgstr "Sierra 2-Row"
+
+#: rayforge/image/dither.py
+msgid "Sierra Lite"
+msgstr "Sierra Lite"
+
+#: rayforge/image/dither.py
+msgid "Burkes"
+msgstr "Burkes"
+
#: rayforge/image/dither.py
msgid "Bayer 2"
msgstr "Bayer 2"
@@ -10470,3 +10900,35 @@ msgstr "Bayer 4"
#: rayforge/image/dither.py
msgid "Bayer 8"
msgstr "Bayer 8"
+
+#: rayforge/image/dither.py
+msgid "Newsprint"
+msgstr "Zeitungsraster"
+
+#: 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."
+
+#: 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 a8c6a76cf..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-06 22:41+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"
@@ -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 ""
@@ -8048,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 ""
@@ -8151,10 +8366,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 +8503,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 +8642,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 +9853,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 +9928,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 +10021,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 +10077,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 ""
@@ -9817,6 +10190,34 @@ msgstr ""
msgid "Floyd Steinberg"
msgstr ""
+#: rayforge/image/dither.py
+msgid "Atkinson"
+msgstr ""
+
+#: rayforge/image/dither.py
+msgid "Stucki"
+msgstr ""
+
+#: rayforge/image/dither.py
+msgid "Jarvis, Judice & Ninke"
+msgstr ""
+
+#: rayforge/image/dither.py
+msgid "Sierra"
+msgstr ""
+
+#: rayforge/image/dither.py
+msgid "Sierra 2-Row"
+msgstr ""
+
+#: rayforge/image/dither.py
+msgid "Sierra Lite"
+msgstr ""
+
+#: rayforge/image/dither.py
+msgid "Burkes"
+msgstr ""
+
#: rayforge/image/dither.py
msgid "Bayer 2"
msgstr ""
@@ -9828,3 +10229,29 @@ msgstr ""
#: rayforge/image/dither.py
msgid "Bayer 8"
msgstr ""
+
+#: rayforge/image/dither.py
+msgid "Newsprint"
+msgstr ""
+
+#: 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 ""
+
+#: 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 9a798cfc7..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-06 22:41+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"
@@ -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"
@@ -8597,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 ""
@@ -8707,10 +8941,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 +9080,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 +9224,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 +10494,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 +10573,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 +10666,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 +10722,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 "No se pudieron descomprimir los datos gzip de este archivo SVGZ."
+
+#: 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."
@@ -10433,6 +10835,34 @@ msgstr "Error al escanear el archivo JPEG: {}"
msgid "Floyd Steinberg"
msgstr "Floyd Steinberg"
+#: rayforge/image/dither.py
+msgid "Atkinson"
+msgstr "Atkinson"
+
+#: rayforge/image/dither.py
+msgid "Stucki"
+msgstr "Stucki"
+
+#: rayforge/image/dither.py
+msgid "Jarvis, Judice & Ninke"
+msgstr "Jarvis, Judice & Ninke"
+
+#: rayforge/image/dither.py
+msgid "Sierra"
+msgstr "Sierra"
+
+#: rayforge/image/dither.py
+msgid "Sierra 2-Row"
+msgstr "Sierra 2-Row"
+
+#: rayforge/image/dither.py
+msgid "Sierra Lite"
+msgstr "Sierra Lite"
+
+#: rayforge/image/dither.py
+msgid "Burkes"
+msgstr "Burkes"
+
#: rayforge/image/dither.py
msgid "Bayer 2"
msgstr "Bayer 2"
@@ -10444,3 +10874,35 @@ msgstr "Bayer 4"
#: rayforge/image/dither.py
msgid "Bayer 8"
msgstr "Bayer 8"
+
+#: rayforge/image/dither.py
+msgid "Newsprint"
+msgstr "Papel prensa"
+
+#: 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."
+
+#: 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 a39af8cae..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-06 22:41+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"
@@ -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"
@@ -8636,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."
@@ -8745,10 +8982,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 +9121,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 +9265,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 +10544,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 +10623,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 +10716,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 +10774,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 "Les données gzip de ce fichier SVGZ n'ont pas pu être décompressées."
+
+#: 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."
@@ -10480,6 +10887,34 @@ msgstr "Échec de l'analyse du fichier JPEG : {}"
msgid "Floyd Steinberg"
msgstr "Floyd Steinberg"
+#: rayforge/image/dither.py
+msgid "Atkinson"
+msgstr "Atkinson"
+
+#: rayforge/image/dither.py
+msgid "Stucki"
+msgstr "Stucki"
+
+#: rayforge/image/dither.py
+msgid "Jarvis, Judice & Ninke"
+msgstr "Jarvis, Judice & Ninke"
+
+#: rayforge/image/dither.py
+msgid "Sierra"
+msgstr "Sierra"
+
+#: rayforge/image/dither.py
+msgid "Sierra 2-Row"
+msgstr "Sierra 2-Row"
+
+#: rayforge/image/dither.py
+msgid "Sierra Lite"
+msgstr "Sierra Lite"
+
+#: rayforge/image/dither.py
+msgid "Burkes"
+msgstr "Burkes"
+
#: rayforge/image/dither.py
msgid "Bayer 2"
msgstr "Bayer 2"
@@ -10491,3 +10926,35 @@ msgstr "Bayer 4"
#: rayforge/image/dither.py
msgid "Bayer 8"
msgstr "Bayer 8"
+
+#: rayforge/image/dither.py
+msgid "Newsprint"
+msgstr "Papier journal"
+
+#: 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."
+
+#: 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 a06a87e8e..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-06 22:41+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"
@@ -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"
@@ -8330,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 "इस स्टेप के जनरेट किए गए टूलपाथ पर लागू ट्रांसफ़ॉर्मर."
@@ -8437,10 +8660,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 +8797,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 +8936,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 +10186,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 +10263,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 +10356,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 +10412,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 "इस SVGZ फ़ाइल का gzip डेटा डीकंप्रेस नहीं किया जा सका।"
+
+#: 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 फ़ाइल में कोई पृष्ठ नहीं है."
@@ -10139,6 +10525,34 @@ msgstr "JPEG फ़ाइल स्कैन करने में विफल
msgid "Floyd Steinberg"
msgstr "Floyd Steinberg"
+#: rayforge/image/dither.py
+msgid "Atkinson"
+msgstr "Atkinson"
+
+#: rayforge/image/dither.py
+msgid "Stucki"
+msgstr "Stucki"
+
+#: rayforge/image/dither.py
+msgid "Jarvis, Judice & Ninke"
+msgstr "Jarvis, Judice & Ninke"
+
+#: rayforge/image/dither.py
+msgid "Sierra"
+msgstr "Sierra"
+
+#: rayforge/image/dither.py
+msgid "Sierra 2-Row"
+msgstr "Sierra 2-Row"
+
+#: rayforge/image/dither.py
+msgid "Sierra Lite"
+msgstr "Sierra Lite"
+
+#: rayforge/image/dither.py
+msgid "Burkes"
+msgstr "Burkes"
+
#: rayforge/image/dither.py
msgid "Bayer 2"
msgstr "Bayer 2"
@@ -10150,3 +10564,34 @@ msgstr "Bayer 4"
#: rayforge/image/dither.py
msgid "Bayer 8"
msgstr "Bayer 8"
+
+#: rayforge/image/dither.py
+msgid "Newsprint"
+msgstr "न्यूज़प्रिंट"
+
+#: 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 इंस्टॉल करें और फिर से कोशिश करें."
+
+#: 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 d5f66bd3f..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-06 22:41+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"
@@ -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"
@@ -8552,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 ""
@@ -8660,10 +8893,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 +9031,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 +9174,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 +10444,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 +10523,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 +10616,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 +10672,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 "Não foi possível descomprimir os dados gzip deste arquivo SVGZ."
+
+#: 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."
@@ -10385,6 +10785,34 @@ msgstr "Falha ao escanear o arquivo JPEG: {}"
msgid "Floyd Steinberg"
msgstr "Floyd Steinberg"
+#: rayforge/image/dither.py
+msgid "Atkinson"
+msgstr "Atkinson"
+
+#: rayforge/image/dither.py
+msgid "Stucki"
+msgstr "Stucki"
+
+#: rayforge/image/dither.py
+msgid "Jarvis, Judice & Ninke"
+msgstr "Jarvis, Judice & Ninke"
+
+#: rayforge/image/dither.py
+msgid "Sierra"
+msgstr "Sierra"
+
+#: rayforge/image/dither.py
+msgid "Sierra 2-Row"
+msgstr "Sierra 2-Row"
+
+#: rayforge/image/dither.py
+msgid "Sierra Lite"
+msgstr "Sierra Lite"
+
+#: rayforge/image/dither.py
+msgid "Burkes"
+msgstr "Burkes"
+
#: rayforge/image/dither.py
msgid "Bayer 2"
msgstr "Bayer 2"
@@ -10396,3 +10824,35 @@ msgstr "Bayer 4"
#: rayforge/image/dither.py
msgid "Bayer 8"
msgstr "Bayer 8"
+
+#: rayforge/image/dither.py
+msgid "Newsprint"
+msgstr "Papel jornal"
+
+#: 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."
+
+#: 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 61ccf8d71..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-06 22:41+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"
@@ -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 ""
@@ -8049,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 ""
@@ -8152,10 +8367,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 +8504,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 +8643,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 +9854,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 +9929,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 +10022,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 +10078,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,6 +10191,34 @@ msgstr ""
msgid "Floyd Steinberg"
msgstr ""
+#: rayforge/image/dither.py
+msgid "Atkinson"
+msgstr ""
+
+#: rayforge/image/dither.py
+msgid "Stucki"
+msgstr ""
+
+#: rayforge/image/dither.py
+msgid "Jarvis, Judice & Ninke"
+msgstr ""
+
+#: rayforge/image/dither.py
+msgid "Sierra"
+msgstr ""
+
+#: rayforge/image/dither.py
+msgid "Sierra 2-Row"
+msgstr ""
+
+#: rayforge/image/dither.py
+msgid "Sierra Lite"
+msgstr ""
+
+#: rayforge/image/dither.py
+msgid "Burkes"
+msgstr ""
+
#: rayforge/image/dither.py
msgid "Bayer 2"
msgstr ""
@@ -9829,3 +10230,29 @@ msgstr ""
#: rayforge/image/dither.py
msgid "Bayer 8"
msgstr ""
+
+#: rayforge/image/dither.py
+msgid "Newsprint"
+msgstr ""
+
+#: 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 ""
+
+#: 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 2f0df73fe..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-06 22:41+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"
@@ -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"
@@ -8515,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 ""
@@ -8624,10 +8858,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 +8995,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 +9136,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 +10406,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 +10491,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 +10584,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 +10640,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 "Не вдалося розпакувати дані gzip цього файлу SVGZ."
+
+#: 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-файл не містить сторінок."
@@ -10345,6 +10753,34 @@ msgstr "Не вдалося сканувати файл JPEG: {}"
msgid "Floyd Steinberg"
msgstr "Floyd Steinberg"
+#: rayforge/image/dither.py
+msgid "Atkinson"
+msgstr "Atkinson"
+
+#: rayforge/image/dither.py
+msgid "Stucki"
+msgstr "Stucki"
+
+#: rayforge/image/dither.py
+msgid "Jarvis, Judice & Ninke"
+msgstr "Jarvis, Judice & Ninke"
+
+#: rayforge/image/dither.py
+msgid "Sierra"
+msgstr "Sierra"
+
+#: rayforge/image/dither.py
+msgid "Sierra 2-Row"
+msgstr "Sierra 2-Row"
+
+#: rayforge/image/dither.py
+msgid "Sierra Lite"
+msgstr "Sierra Lite"
+
+#: rayforge/image/dither.py
+msgid "Burkes"
+msgstr "Burkes"
+
#: rayforge/image/dither.py
msgid "Bayer 2"
msgstr "Bayer 2"
@@ -10356,3 +10792,35 @@ msgstr "Bayer 4"
#: rayforge/image/dither.py
msgid "Bayer 8"
msgstr "Bayer 8"
+
+#: rayforge/image/dither.py
+msgid "Newsprint"
+msgstr "Газетний друк"
+
+#: 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 і спробуйте ще раз."
+
+#: 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 9a23e3ab0..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-06 22:41+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"
@@ -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"
@@ -8162,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 "应用于此步骤生成的刀具路径的转换器。"
@@ -8265,10 +8479,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 +8616,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 +8755,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 +9979,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 +10050,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 +10143,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 +10199,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 "无法解压缩此 SVGZ 文件的 gzip 数据。"
+
+#: 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文件不包含页面。"
@@ -9946,6 +10312,34 @@ msgstr "扫描JPEG文件失败:{}"
msgid "Floyd Steinberg"
msgstr "Floyd Steinberg"
+#: rayforge/image/dither.py
+msgid "Atkinson"
+msgstr "Atkinson"
+
+#: rayforge/image/dither.py
+msgid "Stucki"
+msgstr "Stucki"
+
+#: rayforge/image/dither.py
+msgid "Jarvis, Judice & Ninke"
+msgstr "Jarvis, Judice & Ninke"
+
+#: rayforge/image/dither.py
+msgid "Sierra"
+msgstr "Sierra"
+
+#: rayforge/image/dither.py
+msgid "Sierra 2-Row"
+msgstr "Sierra 2-Row"
+
+#: rayforge/image/dither.py
+msgid "Sierra Lite"
+msgstr "Sierra Lite"
+
+#: rayforge/image/dither.py
+msgid "Burkes"
+msgstr "Burkes"
+
#: rayforge/image/dither.py
msgid "Bayer 2"
msgstr "Bayer 2"
@@ -9957,3 +10351,34 @@ msgstr "Bayer 4"
#: rayforge/image/dither.py
msgid "Bayer 8"
msgstr "Bayer 8"
+
+#: rayforge/image/dither.py
+msgid "Newsprint"
+msgstr "报纸印刷"
+
+#: 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 后"
+"重试。"
+
+#: 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/rayforge/machine/cmd.py b/rayforge/machine/cmd.py
index 420a19056..84bc523fc 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,20 @@ 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.
+ """
+ if include_overscan:
+ return ops.rect()
+ return ops.powered_rect()
+
+
def _frame_rect_corners(
rect: tuple[float, float, float, float],
) -> list[tuple[float, float]]:
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/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/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..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 (
@@ -85,11 +86,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).
@@ -116,6 +122,22 @@ 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)):
+ if len(value) > 1:
+ return str(value[1])
+ if value:
+ return str(value[0])
+ return str(value)
+
+
def _merged_machine_pos(
current: Pos,
pos_x: float | None,
@@ -133,6 +155,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)."""
@@ -181,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
@@ -194,6 +232,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 +344,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 +541,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 +577,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 +597,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 +679,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
@@ -648,6 +715,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):
@@ -660,7 +744,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:
@@ -684,6 +772,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(
@@ -850,11 +946,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:
@@ -879,6 +976,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."""
@@ -1141,10 +1252,18 @@ 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.home_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/driver/ruidarpa/rpa_direct_driver.py b/rayforge/machine/driver/ruidarpa/rpa_direct_driver.py
index 521b2dc82..49ed1a73a 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
@@ -43,6 +44,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 +53,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 +65,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 +81,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
@@ -98,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:
@@ -223,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.
@@ -232,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/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..905760610 100644
--- a/rayforge/machine/models/machine.py
+++ b/rayforge/machine/models/machine.py
@@ -25,10 +25,17 @@
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
+from .bidir_offset import interpolate_bidir_offset
from .coordspace import MachineSpace
from .dialect import GcodeDialect
from .head import Head, head_from_dict
@@ -99,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")
@@ -165,6 +179,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
@@ -181,6 +197,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(
@@ -760,6 +779,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
@@ -778,6 +803,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)."""
@@ -1163,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
@@ -1747,6 +1791,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": [
@@ -1806,6 +1853,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,
},
@@ -1932,6 +1983,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)
@@ -2098,6 +2156,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 f8a540598..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,
@@ -772,6 +753,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 +879,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 +923,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 +932,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 +959,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 +968,59 @@ 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."""
+ 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
+ ) -> 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, 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`.
"""
- 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,
+ "bidir_offset_table": [
+ list(row) for row in self._machine.bidir_offset_table
+ ],
+ }
+ )
+ 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 +1145,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/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/rayforge/pipeline/stage/assembler_helpers.py b/rayforge/pipeline/stage/assembler_helpers.py
index 743ad7118..ffdcbe630 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.
"""
@@ -289,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,
@@ -298,6 +300,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.
@@ -309,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
@@ -327,6 +332,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
@@ -336,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,
@@ -356,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,
@@ -371,15 +377,18 @@ 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
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
@@ -415,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.
@@ -431,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:
@@ -457,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/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
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/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/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 4327dad2d..a3561cf4f 100644
--- a/rayforge/ui_gtk/actions.py
+++ b/rayforge/ui_gtk/actions.py
@@ -1,14 +1,16 @@
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
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
@@ -20,6 +22,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
@@ -108,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
@@ -131,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__}"
)
@@ -144,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
@@ -164,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:
@@ -411,6 +418,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)
@@ -423,6 +436,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,9 +569,37 @@ 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)
+ 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)
+ 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."""
@@ -602,6 +649,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())
@@ -798,6 +883,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/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/context_menu.py b/rayforge/ui_gtk/canvas2d/context_menu.py
index dae5fcb0e..a31796d3e 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")
)
@@ -121,6 +139,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/canvas2d/elements/camera_image.py b/rayforge/ui_gtk/canvas2d/elements/camera_image.py
index bc31df060..6dcee9a32 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,18 +23,62 @@
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)
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)
@@ -42,6 +87,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):
"""
@@ -67,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()
@@ -95,11 +148,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 +190,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 +332,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 +382,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 +392,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/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/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/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/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/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 1c5e8eac1..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()
@@ -622,6 +627,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/path_tolerance_dialog.py b/rayforge/ui_gtk/doceditor/path_tolerance_dialog.py
new file mode 100644
index 000000000..b19491f6d
--- /dev/null
+++ b/rayforge/ui_gtk/doceditor/path_tolerance_dialog.py
@@ -0,0 +1,69 @@
+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)
+ group.set_size_request(430, -1)
+ 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/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..bb9a20eea
--- /dev/null
+++ b/rayforge/ui_gtk/doceditor/source_reload.py
@@ -0,0 +1,149 @@
+"""Watches imported files and offers to reload them when they change."""
+
+from __future__ import annotations
+
+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:
+ 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.Window,
+ editor: DocEditor,
+ watcher: SourceWatcher | None = None,
+ dialog_factory: Callable[..., Any] = SourceChangedDialog,
+ enabled: Callable[[], bool] | None = None,
+ ):
+ 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
+ self._enabled = enabled or _watching_enabled
+
+ 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:
+ if not self._enabled():
+ return True
+ 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."""
+ 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:
+ 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()
+
+
+def _watching_enabled() -> bool:
+ return get_context().config.watch_source_files
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/step_settings/dialog.py b/rayforge/ui_gtk/doceditor/step_settings/dialog.py
index faeb63251..3795995da 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
@@ -196,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
):
@@ -229,6 +257,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/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()
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/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/rayforge/ui_gtk/machine/advanced_preferences_page.py b/rayforge/ui_gtk/machine/advanced_preferences_page.py
index 4263ca6ef..9dc6d9e23 100644
--- a/rayforge/ui_gtk/machine/advanced_preferences_page.py
+++ b/rayforge/ui_gtk/machine/advanced_preferences_page.py
@@ -1,10 +1,13 @@
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
+from .bidir_offset_table import BidirOffsetTableGroup
logger = logging.getLogger(__name__)
@@ -68,6 +71,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(
_(
@@ -99,6 +105,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 +134,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 +157,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/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/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/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/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/main_menu.py b/rayforge/ui_gtk/main_menu.py
index 2c1f4cfe2..bf0425778 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"
@@ -108,9 +112,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/rayforge/ui_gtk/mainwindow.py b/rayforge/ui_gtk/mainwindow.py
index f0928cd82..c4f6f0972 100644
--- a/rayforge/ui_gtk/mainwindow.py
+++ b/rayforge/ui_gtk/mainwindow.py
@@ -45,10 +45,12 @@
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 .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 +197,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:
@@ -970,6 +974,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()
@@ -1620,8 +1628,12 @@ def _on_document_changed(self, sender):
# Initialize new document
self._initialize_document()
- # Check for missing step types and show dialog if needed
- missing_types = new_doc.missing_step_types
+ GLib.idle_add(self._check_changed_sources)
+
+ # 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()
@@ -1632,6 +1644,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,
@@ -2511,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()
@@ -2519,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/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/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/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/requirements.txt b/requirements.txt
index e16bcca8f..779acbc2a 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,24 +1,24 @@
raydriver==0.3.0
-raygeo==1.61.0
-aiohttp==3.14.3
+raygeo==1.66.0
+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
-ruida-pa==0.21.2
+ruida-pa==0.22.0
scipy==1.18.1
semver==3.1.0
svgelements==1.9.6
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" < 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/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/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 880a53fd8..77cf3f920 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",
@@ -51,9 +52,13 @@
"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",
+ "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",
@@ -79,9 +84,11 @@
"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",
+ "main:path-cleanup": "path_cleanup",
"addons:sketcher:conflicts": "sketcher_constraints",
"addons:sketcher:constraints": "sketcher_editor",
"addons:sketcher:constraint:angle": "sketcher_constraints",
@@ -110,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",
@@ -118,6 +126,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",
@@ -126,9 +135,13 @@
"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",
+ "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/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/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/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/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/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/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/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/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/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/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/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"
}
diff --git a/tests/assets/perforation.ryp b/tests/assets/perforation.ryp
new file mode 100644
index 000000000..58ef01d2e
Binary files /dev/null and b/tests/assets/perforation.ryp differ
diff --git a/tests/camera/test_camera.py b/tests/camera/test_camera.py
index 7b54bb528..e31866edb 100644
--- a/tests/camera/test_camera.py
+++ b/tests/camera/test_camera.py
@@ -489,3 +489,87 @@ 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
+ # 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 5103717c9..aaf1f77be 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,
@@ -693,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 = []
@@ -1066,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
@@ -1108,3 +1164,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/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
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_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/core/test_doc_image_edits.py b/tests/core/test_doc_image_edits.py
new file mode 100644
index 000000000..714e4be85
--- /dev/null
+++ b/tests/core/test_doc_image_edits.py
@@ -0,0 +1,72 @@
+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)
+ workflow = doc.active_layer.workflow
+ assert workflow is not None
+ 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_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_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/core/test_step.py b/tests/core/test_step.py
index 8175a1981..0eb378e9a 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 == []
@@ -79,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"
@@ -159,7 +166,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 +182,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 +200,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 +220,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 +295,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 +342,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 +377,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/core/test_workpiece_image_edits.py b/tests/core/test_workpiece_image_edits.py
new file mode 100644
index 000000000..f9789cafc
--- /dev/null
+++ b/tests/core/test_workpiece_image_edits.py
@@ -0,0 +1,238 @@
+from collections.abc import Mapping
+from pathlib import Path
+from typing import Any, ClassVar, cast
+
+import pytest
+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,
+ 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
+
+
+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)),
+ ]
+
+
+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/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
diff --git a/tests/doceditor/test_reload_cmd.py b/tests/doceditor/test_reload_cmd.py
new file mode 100644
index 000000000..ddb1c1a0d
--- /dev/null
+++ b/tests/doceditor/test_reload_cmd.py
@@ -0,0 +1,660 @@
+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.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
+
+PAGE_80 = (
+ ''
+)
+PAGE_120 = (
+ ''
+)
+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_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())
+ (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)
+
+
+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)
+
+
+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
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
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/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/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
diff --git a/tests/image/postscript/test_postscript_import.py b/tests/image/postscript/test_postscript_import.py
new file mode 100644
index 000000000..b8417f17a
--- /dev/null
+++ b/tests/image/postscript/test_postscript_import.py
@@ -0,0 +1,275 @@
+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_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(
+ 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/tests/image/svg/test_svgz.py b/tests/image/svg/test_svgz.py
new file mode 100644
index 000000000..d96364d79
--- /dev/null
+++ b/tests/image/svg/test_svgz.py
@@ -0,0 +1,76 @@
+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_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
+
+
+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
diff --git a/tests/image/test_dither_algorithms.py b/tests/image/test_dither_algorithms.py
new file mode 100644
index 000000000..6f426175d
--- /dev/null
+++ b/tests/image/test_dither_algorithms.py
@@ -0,0 +1,261 @@
+"""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_halftone_dither,
+ apply_minimum_run_length,
+)
+from raygeo.image.srgb import srgb_to_linear
+from scipy import ndimage
+
+from rayforge.image.dither import DitherAlgorithm, grayscale_to_dithered_array
+
+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 _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))
+
+
+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)
+
+
+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)
+
+ 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)
+ 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:
+ 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 = 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):
+ """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 = self._halftone(img, cell_mm, 0.0, (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 = self._halftone(img, 2.0, 0.0, (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)
+ 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 = self._halftone(img, 0.0, 0.0, (10.0, 10.0))
+ assert result.all()
+
+ def test_dispatch_forwards_the_screen_settings_to_raygeo(self):
+ img = np.full((100, 100), 128, dtype=np.uint8)
+ 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(dispatched, expected)
+
+
+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
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/driver/ruidarpa/test_adapter.py b/tests/machine/driver/ruidarpa/test_adapter.py
index 97235e642..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 (
@@ -59,8 +60,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 (
@@ -133,6 +137,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.
@@ -142,6 +147,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
@@ -788,11 +796,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()
@@ -915,6 +924,97 @@ 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 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."""
@@ -933,11 +1033,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()
@@ -1610,6 +1711,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",
@@ -2035,6 +2205,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``."""
@@ -2922,6 +3143,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..128093cc1 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
@@ -17,7 +18,10 @@
_LIVE_METHODS = [
("home", ()),
("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,)),
@@ -72,6 +76,58 @@ 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",
+ )
+
+ 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."""
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/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/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/machine/test_machine_cmd.py b/tests/machine/test_machine_cmd.py
index 3433eb610..4b962e082 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,92 @@ 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
+ 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_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=True) == (
+ 0.0,
+ 0.0,
+ 50.0,
+ 40.0,
+ )
+ ops.rect.assert_called_with()
+
+
class TestDriverFrameDefault:
"""Driver.frame() falls back to an encoded job run."""
diff --git a/tests/pipeline/stage/test_assembler_helpers.py b/tests/pipeline/stage/test_assembler_helpers.py
index 975d8a569..cae47115b 100644
--- a/tests/pipeline/stage/test_assembler_helpers.py
+++ b/tests/pipeline/stage/test_assembler_helpers.py
@@ -286,27 +286,78 @@ 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)
+ def _gradient_surface(self, width=120, height=60):
+ surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height)
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,
+ 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 image is not None
- assert np.all(image[:, :5] == 1)
- assert np.all(image[:, 5:] == 0)
+ 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()
@@ -318,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(
@@ -421,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 5a6c80bee..9c2a56bdc 100644
--- a/tests/pipeline/test_intent_builder.py
+++ b/tests/pipeline/test_intent_builder.py
@@ -30,12 +30,14 @@
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
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
@@ -111,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")
@@ -129,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)
@@ -1293,6 +1295,75 @@ 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)
+
+
+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)
# ----------------------------------------------------------------------
@@ -1385,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),
@@ -1402,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.
@@ -1426,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/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")
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)
diff --git a/tests/shared/test_shared_util_cache.py b/tests/shared/test_shared_util_cache.py
index 681ae3337..dc905745c 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,58 @@ 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[str, int](
+ 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[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[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[str, int](
+ max_entries=10, max_bytes=100, sizeof=lambda v: v
+ )
+ cache.put("a", 40)
+ cache.clear()
+ assert len(cache) == 0
+ assert cache.nbytes == 0
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..18b98366e
--- /dev/null
+++ b/tests/ui_gtk/canvas2d/test_camera_image.py
@@ -0,0 +1,180 @@
+"""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_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
+
+ element = _element(controller, supported=False)
+
+ assert element.clip
+ assert element._outside_margin_request() is None
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()
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()
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/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..ea68b13d4
--- /dev/null
+++ b/tests/ui_gtk/doceditor/test_source_changed_dialog.py
@@ -0,0 +1,244 @@
+"""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.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
+
+
+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,
+ enabled=lambda: True,
+ )
+ 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]
+
+
+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()
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/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
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_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
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 == []
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).
+
+
+
+## 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/addons/image-editing.md b/website/docs/addons/image-editing.md
new file mode 100644
index 000000000..30a2aa3a4
--- /dev/null
+++ b/website/docs/addons/image-editing.md
@@ -0,0 +1,63 @@
+---
+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.
+
+## 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
+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/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/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/docs/features/operations/contour.md b/website/docs/features/operations/contour.md
index b6615b6fd..23ec021ca 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
@@ -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/operations/engrave.md b/website/docs/features/operations/engrave.md
index 536099346..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:**
@@ -229,12 +228,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 +248,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

@@ -264,12 +279,11 @@ traditional halftone printing.
- Creates crosshatch-like 3D effect
- Typical: 0-45 degrees
-**Invert:**
+**Inverting the image:**
-- **Enabled:** White = deep, Black = shallow
-- **Disabled:** Black = deep, White = 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).
### Scan Pattern
@@ -293,7 +307,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
@@ -327,8 +342,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:**
@@ -338,6 +354,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

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.
+
+
+
## 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/docs/features/path-cleanup.md b/website/docs/features/path-cleanup.md
new file mode 100644
index 000000000..2468c09d6
--- /dev/null
+++ b/website/docs/features/path-cleanup.md
@@ -0,0 +1,55 @@
+---
+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.md) 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.md) instead.
+
+## Related Pages
+
+- [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/features/perforation.md b/website/docs/features/perforation.md
new file mode 100644
index 000000000..14cdf970b
--- /dev/null
+++ b/website/docs/features/perforation.md
@@ -0,0 +1,65 @@
+---
+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
+
+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.
+
+
+
+## Typical Uses
+
+Whenever a line should weaken the material without cutting it free, a perforation cut is worth
+considering. The classics:
+
+- **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.
+
+## Enabling Perforation
+
+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.
+
+
+
+## Tips
+
+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 this post-processor belongs to
+- [Holding Tabs](holding-tabs) — gaps placed by hand to keep parts attached
diff --git a/website/docs/features/sketcher/index.md b/website/docs/features/sketcher/index.md
index d0808f956..d961e0767 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,9 +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),
-[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
new file mode 100644
index 000000000..5658b9a25
--- /dev/null
+++ b/website/docs/features/sketcher/polygon.md
@@ -0,0 +1,38 @@
+---
+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/docs/files/formats.md b/website/docs/files/formats.md
index 6ceadb063..8a467b8dd 100644
--- a/website/docs/files/formats.md
+++ b/website/docs/files/formats.md
@@ -13,19 +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 |
-| **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 |
-| **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 |
---
@@ -33,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?**
@@ -220,6 +229,53 @@ 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.
+
+---
+
+### 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.
@@ -287,6 +343,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..1014272a5 100644
--- a/website/docs/files/importing.md
+++ b/website/docs/files/importing.md
@@ -13,21 +13,27 @@ 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`, `.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 |
+| **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
| 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]
@@ -109,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.
+
+
+
+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
@@ -351,7 +398,18 @@ settings.
---
-## Raster Image Import (PNG, JPG, BMP)
+## 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/docs/machine/advanced.md b/website/docs/machine/advanced.md
index 5ee75d5d9..8e8b3a133 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.
@@ -61,6 +70,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.
+
+
+
+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:
+
+
+
+**With the offset table applied:**
+
+
+
+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/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.
+
+
+
+- **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/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
diff --git a/website/docs/ui/canvas-tools.md b/website/docs/ui/canvas-tools.md
index 81db70c84..5507b3afa 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.md) tools.
+
---
**Next**: [3D View →](3d-preview)
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/addons/image-editing.md b/website/i18n/de/docusaurus-plugin-content-docs/current/addons/image-editing.md
new file mode 100644
index 000000000..4a099e8e6
--- /dev/null
+++ b/website/i18n/de/docusaurus-plugin-content-docs/current/addons/image-editing.md
@@ -0,0 +1,61 @@
+# 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.
+
+
+
+
+
+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.
+
+## 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
+ö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/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-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/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..6ecde4603 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
@@ -211,6 +212,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/operations/engrave.md b/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/engrave.md
index 27e5f46ca..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:**
@@ -227,12 +225,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 +246,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

@@ -263,13 +278,11 @@ traditionellem Halbton-Druck ähneln.
- Erzeugt kreuzschraffur-ähnlichen 3D-Effekt
- Typisch: 0-45 Grad
-**Invertieren:**
+**Bild invertieren:**
-- **Aktiviert:** Weiß = tief, Schwarz = flach
-- **Deaktiviert:** Schwarz = tief, Weiß = 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).
### Scan-Muster
@@ -323,15 +336,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:**
@@ -342,6 +356,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

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.
+
+
+
## 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/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..20585be1c
--- /dev/null
+++ b/website/i18n/de/docusaurus-plugin-content-docs/current/features/path-cleanup.md
@@ -0,0 +1,54 @@
+# 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.md), 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.md).
+
+## Verwandte Seiten
+
+- [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/features/perforation.md b/website/i18n/de/docusaurus-plugin-content-docs/current/features/perforation.md
new file mode 100644
index 000000000..4405bc238
--- /dev/null
+++ b/website/i18n/de/docusaurus-plugin-content-docs/current/features/perforation.md
@@ -0,0 +1,69 @@
+---
+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
+
+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.
+
+
+
+## Beispielhafte Anwendungsfälle
+
+Überall dort, wo eine Linie das Material schwächen soll, ohne es zu durchtrennen, lohnt sich ein
+perforierter Schnitt. Die klassischen Fälle:
+
+- **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.
+
+## Perforation aktivieren
+
+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.
+
+
+
+## Tipps
+
+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
+
+- [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/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..8d6f17582
--- /dev/null
+++ b/website/i18n/de/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md
@@ -0,0 +1,41 @@
+---
+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/de/docusaurus-plugin-content-docs/current/files/formats.md b/website/i18n/de/docusaurus-plugin-content-docs/current/files/formats.md
index 1889b7695..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
@@ -7,19 +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 |
-| **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 |
+| **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 |
---
@@ -27,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?**
@@ -215,6 +224,57 @@ 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.
+
+---
+
+### 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.
@@ -282,6 +342,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..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
@@ -7,21 +7,27 @@ 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`, `.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 |
+| **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
| 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]
@@ -107,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.
+
+
+
+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
@@ -357,7 +411,18 @@ farbcodierten Ebenen mit konfigurierbaren Laser-Einstellungen organisiert ist.
---
-## Rasterbild-Import (PNG, JPG, BMP)
+## 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/de/docusaurus-plugin-content-docs/current/machine/advanced.md b/website/i18n/de/docusaurus-plugin-content-docs/current/machine/advanced.md
index 5691550c6..a72ced869 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.
@@ -59,6 +68,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.
+
+
+
+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:
+
+
+
+**Mit angewandter Offset-Tabelle:**
+
+
+
+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/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.
+
+
+
+- **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/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/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..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
@@ -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.md).
+
---
**Weiter**: [3D-Vorschau →](3d-preview)
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/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 ansehenGitHub Repository
+
+
+ Mitwirkungsregeln
+
+ CONTRIBUTING.md
+
Sponsor werden
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..26f6d88f2
--- /dev/null
+++ b/website/i18n/es/docusaurus-plugin-content-docs/current/addons/image-editing.md
@@ -0,0 +1,59 @@
+# 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.
+
+
+
+
+
+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.
+
+## 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
+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/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-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/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..2d9548503 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
@@ -205,6 +206,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/operations/engrave.md b/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/engrave.md
index 58d12d5a4..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:**
@@ -226,12 +225,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 +246,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

@@ -262,12 +279,11 @@ impresión tradicional de media tinta.
- Crea un efecto 3D tipo entramado cruzado
- Típico: 0-45 grados
-**Invertir:**
+**Invertir la imagen:**
-- **Habilitado:** Blanco = profundo, Negro = superficial
-- **Deshabilitado:** Negro = profundo, Blanco = 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).
### Patrón de Escaneo
@@ -320,15 +336,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:**
@@ -339,6 +356,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

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.
+
+
+
## 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/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..d1fcbad2c
--- /dev/null
+++ b/website/i18n/es/docusaurus-plugin-content-docs/current/features/path-cleanup.md
@@ -0,0 +1,52 @@
+# 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.md) 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.md).
+
+## Páginas relacionadas
+
+- [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/features/perforation.md b/website/i18n/es/docusaurus-plugin-content-docs/current/features/perforation.md
new file mode 100644
index 000000000..26e49563a
--- /dev/null
+++ b/website/i18n/es/docusaurus-plugin-content-docs/current/features/perforation.md
@@ -0,0 +1,70 @@
+---
+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
+
+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.
+
+
+
+## Algunos usos típicos
+
+Allí donde una línea deba debilitar el material sin cortarlo por completo, vale la pena un corte
+perforado. Los casos clásicos:
+
+- **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.
+
+## Activar la perforación
+
+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.
+
+
+
+## Consejos
+
+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 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/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..871fa265b
--- /dev/null
+++ b/website/i18n/es/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md
@@ -0,0 +1,42 @@
+---
+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/es/docusaurus-plugin-content-docs/current/files/formats.md b/website/i18n/es/docusaurus-plugin-content-docs/current/files/formats.md
index e104979f8..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
@@ -7,19 +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. |
-| **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 |
-| **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 |
---
@@ -27,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?**
@@ -216,6 +226,57 @@ 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.
+
+---
+
+### 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
@@ -285,6 +346,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..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
@@ -7,21 +7,27 @@ 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`, `.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 |
+| **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
| 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]
@@ -105,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.
+
+
+
+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
@@ -355,7 +408,19 @@ configuraciones láser ajustables.
---
-## Importación de imágenes de mapa de bits (PNG, JPG, BMP)
+## 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
importación.
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..32092f1fa 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.
@@ -58,6 +67,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.
+
+
+
+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:
+
+
+
+**Con la tabla de desplazamiento aplicada:**
+
+
+
+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/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.
+
+
+
+- **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/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/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..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
@@ -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.md).
+
---
**Siguiente**: [Vista Previa 3D →](3d-preview)
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/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ódigoRepositorio de GitHub
+
+
+ Reglas de contribución
+
+ CONTRIBUTING.md
+
Convertirse en Patrocinador
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..d963fc60d
--- /dev/null
+++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/addons/image-editing.md
@@ -0,0 +1,59 @@
+# É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é.
+
+
+
+
+
+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.
+
+## 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
+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/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-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/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..87e3f9bd0 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
@@ -210,6 +211,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/operations/engrave.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/engrave.md
index ca536dd66..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é :**
@@ -227,12 +225,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 +246,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

@@ -263,13 +279,11 @@ ressemblant à l'impression demi-teinte traditionnelle.
- Crée un effet 3D type croisé
- Typique : 0-45 degrés
-**Inverser :**
+**Inversion de l'image :**
-- **Activé :** Blanc = profond, Noir = peu profond
-- **Désactivé :** Noir = profond, Blanc = 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).
### Motif de Balayage
@@ -323,15 +337,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 :**
@@ -342,6 +357,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

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.
+
+
+
## 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/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..ba547d4f6
--- /dev/null
+++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/path-cleanup.md
@@ -0,0 +1,54 @@
+# 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.md) 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.md).
+
+## Pages connexes
+
+- [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/features/perforation.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/perforation.md
new file mode 100644
index 000000000..0ba276d24
--- /dev/null
+++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/perforation.md
@@ -0,0 +1,71 @@
+---
+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
+
+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.
+
+
+
+## Quelques cas d'usage
+
+Chaque fois qu'un trait doit affaiblir le matériau sans le couper, une coupe perforée a du sens. Les
+grands classiques :
+
+- **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.
+
+## Activer la perforation
+
+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.
+
+
+
+## Conseils
+
+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
+
+- [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/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..cee29731a
--- /dev/null
+++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md
@@ -0,0 +1,42 @@
+---
+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/fr/docusaurus-plugin-content-docs/current/files/formats.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/files/formats.md
index 395a1a4e9..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
@@ -7,19 +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 |
-| **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 |
+| **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 |
---
@@ -27,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 ?**
@@ -218,6 +228,57 @@ 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.
+
+---
+
+### 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
@@ -287,6 +348,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..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
@@ -8,21 +8,27 @@ 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`, `.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 |
+| **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
| 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]
@@ -108,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.
+
+
+
+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
@@ -362,7 +416,19 @@ colorés avec des paramètres laser configurables.
---
-## Importation d'images matricielles (PNG, JPG, BMP)
+## 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
dialogue d'importation.
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..179fa0529 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.
@@ -62,6 +71,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.
+
+
+
+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 :
+
+
+
+**Avec le tableau de décalage appliqué :**
+
+
+
+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/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.
+
+
+
+- **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/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/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..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
@@ -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.md).
+
---
**Suivant** : [Prévisualisation 3D →](3d-preview)
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/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 sourceDépôt GitHub
+
+
+ Règles de contribution
+
+ CONTRIBUTING.md
+
Devenir Sponsor
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..cc303c573
--- /dev/null
+++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/addons/image-editing.md
@@ -0,0 +1,57 @@
+# छवि संपादन
+
+छवि संपादन ऐड-ऑन आयातित फ़ाइल को छुए बिना वर्कपीस की छवि का स्वरूप बदलता है. प्रत्येक संपादन वर्कपीस
+के साथ सहेजा जाता है, ताकि आप इसे पूर्ववत कर सकें, बाद में हटा सकें, या उसी फ़ाइल को अलग-अलग
+संपादनों के साथ कई बार रख सकें.
+
+कार्यक्षेत्र, 3D पूर्वावलोकन और हर ऑपरेशन संपादित छवि का उपयोग करते हैं. जो आप कार्यक्षेत्र पर देखते
+हैं, वही लेज़र उकेरता है.
+
+## छवि उलटें
+
+एक या अधिक वर्कपीस चुनें और **Tools - Invert Image** चुनें. उजले क्षेत्र गहरे हो जाते हैं और गहरे
+क्षेत्र उजले हो जाते हैं; पारदर्शी क्षेत्र पारदर्शी रहते हैं, इसलिए पृष्ठभूमि अभी भी नहीं उकेरी
+जाती.
+
+
+
+
+
+उलटना हटाने के लिए इसे फिर से चुनें. मिश्रित चयन के साथ, जो वर्कपीस अभी तक नहीं उलटे गए हैं वे उलट
+दिए जाते हैं, ताकि पूरा चयन अंततः समान हो जाए.
+
+इसे उत्कीर्णन ऑपरेशन में लिथोफ़ेन और उभार के लिए उपयोग करें, जहाँ उजले क्षेत्र गहरे उकेरे जाने
+चाहिए, या गहरी पृष्ठभूमि पर उजली आकृति उकेरने के लिए.
+
+## छवि समायोजित करें
+
+एक या अधिक वर्कपीस चुनें और किसी फ़ोटो को एनग्रेव या ट्रेस करने से पहले ठीक करने के लिए **Tools -
+Adjust Image...** चुनें:
+
+- **Brightness** (चमक) छवि को हल्का या गहरा करता है।
+- **Contrast** (कंट्रास्ट) टोन को मध्य-ग्रे से दूर फैलाता है या उसकी ओर खींचता है।
+- **Gamma** 1 से ऊपर मध्य-टोन को हल्का करता है, 1 से नीचे उन्हें गहरा करता है; काला और सफ़ेद जैसे
+ हैं वैसे ही रहते हैं।
+- **Sharpen** (शार्पन) किनारों और बारीक विवरणों को मज़बूत करता है (एक अनशार्प मास्क)। **Sharpen
+ Radius** उन विवरणों का आकार मिलीमीटर में तय करता है, इसलिए परिणाम किसी भी लाइन अंतराल या ज़ूम पर
+ एक जैसा रहता है।
+
+
+
+संपादन करते समय कैनवास बदलाव दिखाता है। **Apply** उन्हें एक ही अनडू चरण के रूप में रखता है; बिना
+लागू किए डायलॉग बंद करने पर वे हट जाते हैं, और **Reset** हर मान को फिर से तटस्थ कर देता है। पारदर्शी
+क्षेत्र पारदर्शी ही रहते हैं।
+
+डायलॉग पहले चुने गए वर्कपीस के मानों के साथ खुलता है, और Apply हर चुने गए वर्कपीस को वही मान देता
+है। शार्पनिंग टोन सुधारों से पहले होती है, और Invert Image जैसे अन्य संपादन अपनी जगह पर रहते हैं।
+
+## पुराने प्रोजेक्ट
+
+इस ऐड-ऑन से पहले, उलटें उत्कीर्णन ऑपरेशन की एक सेटिंग थी. जब आप कोई पुराना प्रोजेक्ट खोलते हैं जिसने
+इसका उपयोग किया था, तो Rayforge उस सेटिंग को उस लेयर के वर्कपीस पर ले जाता है, ताकि परिणाम वही रहे.
+
+## जब ऐड-ऑन अक्षम हो
+
+ऐड-ऑन के अक्षम या इंस्टॉल न होने पर प्रोजेक्ट अपने छवि संपादन बनाए रखता है. Rayforge आपको बताता है
+कि प्रोजेक्ट अनुपलब्ध फ़ीचर का उपयोग करता है और ऐड-ऑन के फिर से सक्षम होने तक उन संपादनों के बिना
+छवियाँ दिखाता है.
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-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/features/operations/contour.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/contour.md
index 6b3031fdc..adf685caf 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**, और
+**वर्कपीस**. सेटिंग्स नीचे टैब क्रम में वर्णित हैं.
### कॉन्टूर सेटिंग्स
@@ -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/operations/engrave.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/engrave.md
index 399dba324..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) देखें.
**चमक सीमा:**
@@ -230,12 +229,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 +249,15 @@ _Step Settings_ टैब के **उत्कीर्णन** और **शक
**Bayer डाइदरिंग** नियमित पैटर्न बनाती है जो पारंपरिक हाफ़टोन छपाई जैसे कलात्मक प्रभाव दे सकते हैं.
+**Stucki, Jarvis, Sierra, Burkes और Atkinson** Floyd-Steinberg की तरह त्रुटि-प्रसार एल्गोरिदम हैं,
+जो त्रुटि को अधिक बड़े पड़ोस में फैलाते हैं. इससे त्वचा टोन जैसे समतल क्षेत्रों में दाना अधिक चिकना
+होता है. Atkinson त्रुटि का केवल एक भाग फैलाता है, जिससे कंट्रास्ट बढ़ता है. हर पंक्ति में स्कैन
+दिशा बदलने और दिशात्मक धारियों से बचने के लिए **सर्पाकार** चालू करें.
+
+**अख़बारी छपाई** और **हाफ़टोन** पिक्सेल को बिंदुओं में समूहित करते हैं. हाफ़टोन एक ग्रिड पर गोल
+बिंदु बनाता है, जिसे **हाफ़टोन सेल आकार** (बिंदु केंद्रों के बीच की दूरी) और **हाफ़टोन कोण** से तय
+किया जाता है; सेल आकार कम से कम कुछ लेज़र बिंदु चौड़ाई जितना रखें.
+
#### अनेक गहराइयाँ मोड सेटिंग्स

@@ -265,12 +280,11 @@ _Step Settings_ टैब के **उत्कीर्णन** और **शक
- क्रॉसहैच जैसा 3D प्रभाव बनाता है
- सामान्य: 0-45 डिग्री
-**उलटें:**
+**छवि उलटें:**
-- **सक्षम:** सफ़ेद = गहरा, काला = उथला
-- **अक्षम:** काला = गहरा, सफ़ेद = उथला
-
-लिथोफ़ेन (उजले क्षेत्र पतले होने चाहिए) या उभार (उठे हुए क्षेत्र) के लिए उलटें उपयोग करें.
+काला गहरा उकेरा जाता है और सफ़ेद उथला रहता है. उन्हें बदलने के लिए, लिथोफ़ेन (उजले क्षेत्र पतले होने
+चाहिए) या उभार (उठे हुए क्षेत्र) के लिए, वर्कपीस चुनें और **Tools - Invert Image** का उपयोग करें.
+[छवि संपादन](../../addons/image-editing.md) देखें.
### स्कैन पैटर्न
@@ -294,7 +308,8 @@ _Step Settings_ टैब के **उत्कीर्णन** और **शक
:::tip[रेज़ोल्यूशन मेल]
छवियों के लिए, रेखा अंतराल छवि रेज़ोल्यूशन से मेल खाना चाहिए या उससे अधिक बारीक होना
-चाहिए. यदि आपकी छवि 10 पिक्सेल/mm (254 DPI) है, तो 0.1mm रेखा अंतराल या छोटा उपयोग करें.:::
+चाहिए. यदि आपकी छवि 10 पिक्सेल/mm (254 DPI) है, तो 0.1mm रेखा अंतराल या छोटा उपयोग करें.
+:::
#### स्कैन दिशा
@@ -328,8 +343,9 @@ Rayforge हमेशा द्विदिशात्मक स्कैन
दिखाई देता है, विशेष रूप से फ़ोटो उत्कीर्णनों में).
- मिलीमीटर में सेट, धनात्मक या ऋणात्मक इस पर निर्भर कि किस दिशा को खिसकाना है
-- गति चाहे जो हो एक स्थिर खिसकाव लागू करता है; यदि तिरछापन गति के साथ बदलता है, तो अपनी सामान्य
- उत्कीर्णन गति के लिए कैलिब्रेट करें
+- यहाँ शून्येतर मान हमेशा वरीयता रखता है; जब यह 0 होता है, तो चरण मशीन की
+ [गति के अनुसार द्विदिशात्मक स्कैन ऑफ़सेट](../../machine/advanced.md#bidirectional-scan-offset-by-speed)
+ तालिका का उपयोग करता है, जो इस चरण की गति के लिए एक ऑफ़सेट इंटरपोलेट करती है
- डिफ़ॉल्ट 0 (कोई सुधार नहीं)
**ऑफ़सेट कैलिब्रेट करना:**
@@ -340,6 +356,11 @@ Rayforge हमेशा द्विदिशात्मक स्कैन
3. ऑफ़सेट को छोटी वृद्धि (0.01-0.05mm) में समायोजित करें और बारी-बारी पंक्तियाँ संरेखित होने तक पुनः
परीक्षण करें
+यदि गलत संरेखण आपकी सामान्य ग्रेविंग गतियों में स्पष्ट रूप से बदलता है, तो एक अकेला ऑफ़सेट हर जॉब को
+ठीक नहीं कर सकता. चरण का ऑफ़सेट 0 पर छोड़ दें और इसके बजाय
+[मशीन सेटिंग्स - उन्नत](../../machine/advanced.md#bidirectional-scan-offset-by-speed) में एक
+गति-निर्भर ऑफ़सेट तालिका भरें.
+
## लेज़र सेटिंग्स

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
+## जुड़ा हुआ वर्कपीस
+
+आम तौर पर एक ऑपरेशन अपनी लेयर के सभी वर्कपीस प्रोसेस करता है, और उसके सेटिंग्स संवाद का **वर्कपीस**
+टैब उन सभी को शामिल के रूप में सूचीबद्ध करता है. सामग्री परीक्षण ग्रिड इसका अपवाद है: इसका ऑपरेशन उस
+वर्कपीस से जुड़ा है जो उसने बनाया और केवल उसी को प्रोसेस करता है. वर्कपीस टैब पर बनाया गया ग्रिड
+एकमात्र शामिल वर्कपीस के रूप में दिखता है, जबकि लेयर का बाकी सब कुछ बाहर चिह्नित होता है.
+
+
+
## ग्रिड लेआउट समझना
### ग्रिड संगठन
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/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..803b48b45
--- /dev/null
+++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/path-cleanup.md
@@ -0,0 +1,49 @@
+# पथ सफ़ाई
+
+दूसरे प्रोग्राम से आए लोगो और ड्रॉइंग हमेशा साफ़ नहीं होते. कोई आउटलाइन अपने आरंभ बिंदु से एक
+मिलीमीटर के अंश पहले रुक सकती है, कोई आकृति कई खुले टुकड़ों से बनी हो सकती है, या एक ही पथ फ़ाइल में
+दो बार हो सकता है. Rayforge ऐसे पथों को ठीक वैसे ही काटता है जैसे वे हैं: जो आकृति केवल बंद दिखती है
+उसे खुला माना जाता है, और दोहरा पथ दो बार काटा जाता है.
+
+पथ सफ़ाई टूल चुने गए वर्कपीस पर इसे ठीक करते हैं. ये **ऑब्जेक्ट** मेनू में और कैनवस के संदर्भ मेनू
+में **पथ साफ़ करें** के अंतर्गत मिलते हैं. हर टूल एक ही पूर्ववत चरण है.
+
+
+
+## पथ बंद करें
+
+**पथ बंद करें…** हर उस खुले पथ को बंद करता है जिसके आरंभ और अंत बिंदु आपकी दी गई सहनशीलता से अधिक
+निकट हैं. अंतराल को दोनों मौजूदा बिंदुओं के बीच एक सीधी रेखा से जोड़ा जाता है, इसलिए आकृति न हिलती
+है और न उसका आकार बदलता है.
+
+## खुले पथ जोड़ें
+
+**खुले पथ जोड़ें…** उन खुले पथों को लंबे पथों में जोड़ता है जिनके अंत बिंदु सहनशीलता से अधिक निकट
+हैं. ज़रूरत होने पर टुकड़ों की दिशा उलट दी जाती है, इसलिए यह मायने नहीं रखता कि उन्हें किस क्रम और
+दिशा में बनाया गया था. अगर जुड़े हुए पथ के सिरे फिर मिल जाते हैं, तो पथ बंद भी कर दिया जाता है.
+
+दोनों टूल सत्र के दौरान आपकी पिछली सहनशीलता याद रखते हैं. ज़्यादातर आयात की गई फ़ाइलों के लिए 0.05
+mm से 0.2 mm के बीच की सहनशीलता ठीक रहती है.
+
+## डुप्लिकेट हटाएँ
+
+**डुप्लिकेट हटाएँ** उन पथों को हटाता है जो उसी वर्कपीस के किसी दूसरे पथ के ठीक ऊपर हैं, चाहे उनकी
+दिशा या आरंभ बिंदु कुछ भी हो. अगर आप कई वर्कपीस चुनते हैं, तो वह वर्कपीस भी हटा दिया जाता है जो उसी
+स्थान पर किसी दूसरे चुने गए वर्कपीस की हूबहू प्रति है.
+
+जो पथ अपनी लंबाई के केवल एक हिस्से में ओवरलैप करते हैं, वे बने रहते हैं. जॉब बनाते समय
+[रेखाएँ मर्ज करें](./merge-lines.md) पोस्ट-प्रोसेसर उन्हें संभालता है.
+
+## अलग-अलग करें
+
+**अलग-अलग करें** किसी वर्कपीस के हर पथ को उसका अपना वर्कपीस बना देता है, छेद और खुले पथ भी. यह
+**विभाजित करें** से अलग है, जो किसी द्वीप को उसके छेदों के साथ एक रखता है.
+
+पथ सफ़ाई केवल आयात किए गए और ट्रेस किए गए वर्कपीस पर काम करती है. स्केच से बने वर्कपीस को
+[स्केचर](./sketcher/index.md) में संपादित करें.
+
+## संबंधित पृष्ठ
+
+- [रेखाएँ मर्ज करें](./merge-lines.md) - ओवरलैप होने वाले खंडों को केवल एक बार काटना
+- [कैनवस उपकरण](../ui/canvas-tools.md) - अलग-अलग खंड चुनना और हटाना
+- [फ़ाइलें आयात करना](../files/importing.md) - Rayforge में डिज़ाइन लाना
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..7c5d4ec5a
--- /dev/null
+++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/perforation.md
@@ -0,0 +1,63 @@
+---
+description:
+ "Rayforge में परफोरेशन पोस्ट-प्रोसेसर से डैश वाली लाइनें काटें: मोड़, फाड़ने की लाइन और सिलाई के
+ छेदों के लिए कट लंबाई और स्किप लंबाई सेट करें।"
+---
+
+# परफोरेशन
+
+हर कट का मकसद मटेरियल से पार्ट अलग करना नहीं होता। कुछ लाइनें इसलिए होती हैं कि मटेरियल उन पर मुड़े,
+साफ फटे, या सुई का रास्ता पहले से निशान हो। इसी काम के लिए **परफोरेशन** पोस्ट-प्रोसेसर है: यह लगातार
+कट को डैश वाले कट में बदल देता है। लेज़र को शुरू से आखिर तक जलाए रखने के बजाय यह **कट लंबाई** तक
+चलता है, फिर **स्किप लंबाई** तक लेज़र बंद करके आगे बढ़ता है, और वर्कपीस के हर कंटूर पर यही लय
+दोहराता है। नतीजा छोटे-छोटे कटों की एक लाइन होती है जो मटेरियल को जोड़े रखती है — जब तक आप खुद उसे
+मोड़ना या फाड़ना न चाहें।
+
+
+
+## कुछ सामान्य उपयोग
+
+जहाँ भी कोई लाइन मटेरियल को काटे बिना कमज़ोर करना चाहती है, वहाँ परफोरेटेड कट काम आता है। कुछ प्रमुख
+उदाहरण:
+
+- कार्ड स्टॉक और नालीदार गत्ते में **मोड़ की लाइनें** डैश वाले कट पर बिल्कुल साफ मुड़ती हैं — जैसे
+ डिब्बे, कार्ड और पॉप-अप डिज़ाइन।
+- **फाड़ने की लाइनें** टिकट, कूपन और पैकेजिंग बिना कैंची खोल देती हैं।
+- चमड़े में **सिलाई के छेद** सिलाई से पहले सुई का रास्ता दिखा देते हैं।
+- **डैश वाली कंटूरें** अपने आप में भी एक सजावटी अंदाज़ हैं — लकड़ी, कागज़ और ऐक्रेलिक पर।
+
+## परफोरेशन चालू करना
+
+परफोरेशन [कंटूर](operations/contour) स्टेप की सेटिंग्स में **पोस्ट-प्रोसेसिंग** पेज पर मिलती है,
+बाकी पोस्ट-प्रोसेसरों के साथ। यह डिफ़ॉल्ट रूप से बंद रहती है; स्विच चालू करते ही दो मान सामने आते
+हैं:
+
+- **कट लंबाई**: हर गैप से पहले लेज़र जितनी दूरी तक जलता रहता है।
+- **स्किप लंबाई**: दो कटों के बीच लेज़र बंद करके तय की गई दूरी।
+
+पैटर्न हर कंटूर की शुरुआत में एक पूरे कट के साथ शुरू होता है और हर कंटूर पर नए सिरे से शुरू होता है,
+ताकि वर्कपीस की सारी डैश वाली लाइनें एक जैसे अंदाज़ में शुरू हों। जो कंटूर एक कट और एक स्किप के लिए
+भी छोटा हो, उसे पूरा काट दिया जाता है।
+
+## नतीजा
+
+कैनवस पर डैश वाली लाइन सीधे कट की सॉलिड रूपरेखा की जगह ले लेती है — टूलपाथ का हर गैप तैयार पार्ट में
+भी गैप ही होता है।
+
+
+
+## सुझाव
+
+जिस मोड़ की लाइन को आर-पार नहीं काटना है, उसके लिए कट लंबाई जितनी स्किप लंबाई से शुरू करें और पावर
+कम करें — उथली नाली गहरे डैश वाले कट जितनी ही अच्छी मुड़ती है, और पिछला हिस्सा साफ रहता है।
+
+बहुत छोटी लंबाइयाँ (मिलीमीटर के कुछ दसवें हिस्से) लेज़र को तेज़ी से चालू-बंद करवाती हैं, जिससे
+प्रभावी पावर घटती है और असमान निशान पड़ सकते हैं। पैटर्न असमान लगे तो लंबी वैल्यू आज़माएँ।
+
+परफोरेशन बाकी पोस्ट-प्रोसेसिंग के साथ अच्छी तरह चलती है: [होल्डिंग टैब](holding-tabs),
+[लीड-इन / लीड-आउट](lead-in-out) और [मल्टी-पास](multi-pass) पहले की तरह ही काम करते रहते हैं।
+
+## संबंधित पेज
+
+- [कॉन्टूर कटिंग](operations/contour) — वह कटिंग ऑपरेशन जिससे यह पोस्ट-प्रोसेसर जुड़ा है
+- [होल्डिंग टैब](holding-tabs) — पार्टों को जोड़े रखने के लिए हाथ से लगाए गए गैप
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..48459dcdc
--- /dev/null
+++ b/website/i18n/hi/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/hi/docusaurus-plugin-content-docs/current/files/formats.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/files/formats.md
index 586d99127..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
@@ -18,11 +18,17 @@ 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 नियंत्रक जॉब फ़ाइलें |
+| **HPGL** | वेक्टर | ✓ Direct | – | प्लॉटर और विनाइल कटर फ़ाइलें |
| **PNG** | रास्टर | ✓ Trace | – | फ़ोटो, छवियाँ |
| **JPEG** | रास्टर | ✓ Trace | – | फ़ोटो |
| **BMP** | रास्टर | ✓ Trace | – | सरल ग्राफ़िक्स |
+| **GIF** | रास्टर | ✓ Trace | – | सरल ग्राफ़िक्स, एनिमेशन |
+| **TIFF** | रास्टर | ✓ Trace | – | स्कैन, प्रिंट आर्टवर्क |
+| **WebP** | रास्टर | ✓ Trace | – | वेब छवियाँ |
| **RFS** | स्केच | ✓ Direct | ✓ Object export | पैरामीट्रिक स्केच |
| **G-code** | नियंत्रण | – | ✓ प्राथमिक | मशीन आउटपुट |
| **Rayforge परियोजना** | परियोजना | ✓ | ✓ | परियोजनाएँ सहेजें/लोड करें |
@@ -33,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 क्या है?**
@@ -205,6 +214,54 @@ 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 वाले कंप्यूटर पर भी खुलता है.
+
+---
+
+### 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 प्लॉटर इकाई प्रति मिलीमीटर का उपयोग करते हैं
+
+---
+
## रास्टर फ़ॉर्मेट
सभी रास्टर फ़ॉर्मेट **ट्रेस करके आयात** होते हैं - स्वतः वेक्टर पाथ में बदले जाते हैं.
@@ -272,6 +329,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..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
@@ -13,21 +13,27 @@ Rayforge विभिन्न फ़ाइल फ़ॉर्मेट आय
### वेक्टर फ़ॉर्मेट
-| फ़ॉर्मेट | एक्सटेंशन | आयात विधि | सर्वोत्तम के लिए |
-| ------------- | ----------------- | -------------------- | -------------------------------- |
-| **SVG** | `.svg` | सीधे वेक्टर या ट्रेस | वेक्टर ग्राफ़िक्स, लोगो, डिज़ाइन |
-| **DXF** | `.dxf` | सीधे वेक्टर | CAD चित्र, तकनीकी डिज़ाइन |
-| **PDF** | `.pdf` | सीधे वेक्टर या ट्रेस | वेक्टर सामग्री युक्त दस्तावेज़ |
-| **LightBurn** | `.lbrn`, `.lbrn2` | सीधे वेक्टर | LightBurn परियोजना फ़ाइलें |
-| **Ruida** | `.rd` | सीधे वेक्टर | Ruida नियंत्रक जॉब फ़ाइलें |
+| फ़ॉर्मेट | एक्सटेंशन | आयात विधि | सर्वोत्तम के लिए |
+| --------------- | ----------------- | ----------------------------------------- | -------------------------------- |
+| **SVG** | `.svg`, `.svgz` | सीधे वेक्टर या ट्रेस | वेक्टर ग्राफ़िक्स, लोगो, डिज़ाइन |
+| **DXF** | `.dxf` | सीधे वेक्टर | CAD चित्र, तकनीकी डिज़ाइन |
+| **PDF** | `.pdf` | सीधे वेक्टर या ट्रेस | वेक्टर सामग्री युक्त दस्तावेज़ |
+| **LightBurn** | `.lbrn`, `.lbrn2` | सीधे वेक्टर | LightBurn परियोजना फ़ाइलें |
+| **Ruida** | `.rd` | सीधे वेक्टर | Ruida नियंत्रक जॉब फ़ाइलें |
+| **Illustrator** | `.ai` | सीधे वेक्टर या ट्रेस | Illustrator से लोगो |
+| **EPS** | `.eps`, `.ps` | सीधे वेक्टर या ट्रेस (Ghostscript ज़रूरी) | लोगो, प्रिंट आर्टवर्क |
+| **HPGL** | `.plt`, `.hpgl` | सीधे वेक्टर | प्लॉटर और विनाइल कटर फ़ाइलें |
### रास्टर फ़ॉर्मेट
| फ़ॉर्मेट | एक्सटेंशन | आयात विधि | सर्वोत्तम के लिए |
| -------- | --------------- | ---------------- | ----------------------------- |
-| **PNG** | `.webp` | वेक्टर में ट्रेस | फ़ोटो, पारदर्शिता वाली छवियाँ |
+| **PNG** | `.png` | वेक्टर में ट्रेस | फ़ोटो, पारदर्शिता वाली छवियाँ |
| **JPEG** | `.jpg`, `.jpeg` | वेक्टर में ट्रेस | फ़ोटो, सतत-टोन छवियाँ |
| **BMP** | `.bmp` | वेक्टर में ट्रेस | सरल ग्राफ़िक्स, स्क्रीनशॉट |
+| **GIF** | `.gif` | वेक्टर में ट्रेस | सरल ग्राफ़िक्स, एनिमेशन |
+| **TIFF** | `.tif`, `.tiff` | वेक्टर में ट्रेस | स्कैन, प्रिंट आर्टवर्क |
+| **WebP** | `.webp` | वेक्टर में ट्रेस | वेब छवियाँ |
:::note[रास्टर आयात]
@@ -109,6 +115,48 @@ rayforge file1.svg file2.dxf
यह सुनिश्चित करता है कि आपके डिज़ाइन हमेशा आपकी मशीन की क्षमताओं के भीतर फ़िट हों, साथ ही आवश्यकता
पड़ने पर मूल आकार पुनर्स्थापित करने का लचीलापन भी देते हैं.
+### आयात की गई फ़ाइलों को सिंक में रखना
+
+फ़ाइल आयात करते समय Rayforge डिस्क पर उसका एक लिंक बनाए रखता है. यदि फ़ाइल बाद में बदलती है — उदाहरण
+के लिए Inkscape या अपने CAD प्रोग्राम में उसे संपादित करके दोबारा सहेजने के बाद — तो Rayforge आयातित
+सामग्री को फिर से लोड करने का प्रस्ताव देता है, ताकि आपके प्रोजेक्ट में पुरानी ज्यामिति न रहे.
+
+
+
+यह सुविधा 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 आयात
@@ -351,7 +399,18 @@ LightBurn (.lbrn / .lbrn2) फ़ाइलें LightBurn लेज़र क
---
-## रास्टर छवि आयात (PNG, JPG, BMP)
+## 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/hi/docusaurus-plugin-content-docs/current/machine/advanced.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/machine/advanced.md
index 5a47b226d..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
@@ -33,6 +33,15 @@ description:
सक्षम होने पर, आप व्यक्तिगत अक्षों को स्वतंत्र रूप से होम कर सकते हैं (X, Y, या Z) बजाय इसके कि सभी
अक्ष एक साथ होम हों. यह उन मशीनों के लिए उपयोगी है जहाँ एक अक्ष पहले से सही स्थित में हो सकता है.
+### Z होमिंग
+
+चुनता है कि होम करते समय Z अक्ष अपनी संदर्भ स्थिति कैसे ढूँढता है. यह सेटिंग तभी दिखती है जब मशीन का
+ड्राइवर एक से अधिक तरीकों का समर्थन करता है.
+
+- **होम स्विच**: Z, X और Y अक्षों की तरह, अपने होम स्विच तक जाता है
+- **प्रोब**: Z कंट्रोलर के ऑटो-फ़ोकस प्रोब से कार्य सतह ढूँढता है, फिर सेट की गई फ़ोकस दूरी पर जाता
+ है
+
## चाप और वक्र सेटिंग्स
नियंत्रित करने की सेटिंग्स कि वक्र पाथ G-code गतियों में कैसे बदले जाते हैं.
@@ -60,6 +69,41 @@ description:
सामान्य मान परिशुद्धता कार्य के लिए 0.01mm से तेज़ प्रोसेसिंग के लिए 0.1mm तक होते हैं.
+## गति के अनुसार द्विदिशात्मक स्कैन ऑफ़सेट {#bidirectional-scan-offset-by-speed}
+
+गति के अनुसार द्विदिशात्मक स्कैन ऑफ़सेट की तालिका बाएँ-से-दाएँ और दाएँ-से-बाएँ रैस्टर पास के बीच
+क्षैतिज गलत संरेखण को ठीक करती है. कई मशीनों पर यह विचलन स्थिर नहीं होता: लेज़र हेड जितनी तेज़ चलता
+है, दाएँ-से-बाएँ पास उतना ही अधिक संरेखण से हट जाते हैं, जो बैंडिंग के रूप में दिखाई देता है और अधिक
+ग्रेविंग गति पर बदतर होता जाता है.
+
+
+
+तालिका की प्रत्येक पंक्ति एक ग्रेविंग गति को उस गति पर दाएँ-से-बाएँ पास के लिए आवश्यक ऑफ़सेट
+(मिलीमीटर में) के साथ जोड़ती है:
+
+- पंक्ति जोड़ने के लिए **पंक्ति जोड़ें** का और हटाने के लिए पंक्ति का कचरा बटन उपयोग करें
+- धनात्मक मान दाएँ-से-बाएँ पास को दाईं ओर और ऋणात्मक मान बाईं ओर खिसकाते हैं
+- पंक्तियाँ गति के अनुसार क्रमबद्ध रहती हैं; यदि दो पंक्तियों की गति समान है, तो अंतिम वाली मानी
+ जाती है
+
+जब कोई ग्रेविंग चरण चलता है, तो Rayforge चरण की गति को इस तालिका में देखता है और आस-पास की पंक्तियों
+के बीच ऑफ़सेट को रैखिक रूप से इंटरपोलेट करता है. पहली पंक्ति से नीचे या अंतिम पंक्ति से ऊपर की
+गतियाँ निकटतम पंक्ति का ऑफ़सेट उपयोग करती हैं, और खाली तालिका का अर्थ है कि हर गति 0 ऑफ़सेट उपयोग
+करती है.
+
+**बिना सुधार के** — गलत संरेखण ग्रेविंग गति के साथ बढ़ता है:
+
+
+
+**ऑफ़सेट तालिका लागू होने पर:**
+
+
+
+यह तालिका केवल उन ग्रेविंग चरणों पर लागू होती है जिनका अपना **द्विदिशात्मक स्कैन ऑफ़सेट** 0 है;
+शून्येतर चरण-स्तरीय ऑफ़सेट को हमेशा प्राथमिकता मिलती है. गलत संरेखण कैसा दिखता है और इसे कैसे
+कैलिब्रेट करें, इसके लिए
+[द्विदिशात्मक स्कैन ऑफ़सेट](../features/operations/engrave.md#bidirectional-scan-offset) देखें.
+
## यह भी देखें
- [हार्डवेयर सेटिंग्स](hardware) - अक्ष मूल और फ़्लिप सेटिंग्स
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 विकल्प तब उपयोगी है जब आपका क
---
+### वर्कस्पेस के बाहर का दृश्य
+
+कैमरा आमतौर पर केवल वर्कस्पेस से अधिक देखता है. मशीन सेटिंग्स → कैमरा → वर्कस्पेस के बाहर का दृश्य
+में, आप वर्कस्पेस के चारों ओर कैमरा इमेज का एक अतिरिक्त अर्ध-पारदर्शी किनारा दिखा सकते हैं, जिससे
+वर्कपीस को स्थित करना आसान हो जाता है.
+
+
+
+- **वर्कस्पेस के बाहर दिखाएं** किनारे को चालू या बंद करता है. यह डिफ़ॉल्ट रूप से बंद रहता है और
+ संरेखित कैमरे की आवश्यकता होती है.
+- **मार्जिन** यह नियंत्रित करता है कि किनारा वर्कस्पेस से कितनी दूर तक फैले, 100 मिमी तक.
+- **पारदर्शिता** किनारे की दृश्यता को नियंत्रित करती है.
+
+जहां कैमरा नहीं देख सकता, वहां काला दिखाने के बजाय पारदर्शी रहता है, और किनारा उसी सीमे पर समाप्त
+होता है जहां तक कैमरा वास्तव में देखता है. बेड से ऊपर उठी वस्तुएं, जैसे गैंट्री, किनारे में खिसकी
+हुई दिख सकती हैं, क्योंकि कैमरा इमेज बेड के तल से संरेखित होती है.
+
+---
+
### कई कैमरे
Rayforge भिन्न दृश्यों या मशीनों के लिए कई कैमरे समर्थन करता है:
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/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..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
@@ -108,6 +108,9 @@ description:
वेक्टर संपादन मोड से बाहर निकलने के लिए, वर्कपीस के बाहर क्लिक करें या Escape दबाएँ.
+अंतराल बंद करने, खुले टुकड़ों को जोड़ने, डुप्लिकेट पथ हटाने या किसी वर्कपीस को अलग-अलग करने के लिए
+[पथ सफ़ाई](../features/path-cleanup.md) टूल का उपयोग करें.
+
---
**अगला:** [3D दृश्य →](3d-preview)
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/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/addons/image-editing.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/addons/image-editing.md
new file mode 100644
index 000000000..2959c38b4
--- /dev/null
+++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/addons/image-editing.md
@@ -0,0 +1,59 @@
+# 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.
+
+
+
+
+
+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.
+
+## 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
+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/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-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/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..cc76aad24 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
@@ -207,6 +208,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/operations/engrave.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/engrave.md
index 141e4901f..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:**
@@ -226,12 +225,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 +246,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

@@ -262,12 +278,11 @@ impressão tradicional de meio-tom.
- Cria efeito 3D estilo malha cruzada
- Típico: 0-45 graus
-**Inverter:**
+**Invertendo a imagem:**
-- **Habilitado:** Branco = profundo, Preto = raso
-- **Desabilitado:** Preto = profundo, Branco = 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).
### Padrão de Varredura
@@ -320,15 +335,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:**
@@ -339,6 +355,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

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.
+
+
+
## 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/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..9e0893efc
--- /dev/null
+++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/path-cleanup.md
@@ -0,0 +1,53 @@
+# 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.md) 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.md).
+
+## Páginas relacionadas
+
+- [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/features/perforation.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/perforation.md
new file mode 100644
index 000000000..3235e9eff
--- /dev/null
+++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/perforation.md
@@ -0,0 +1,67 @@
+---
+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
+
+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.
+
+
+
+## Alguns usos típicos
+
+Sempre que uma linha deve enfraquecer o material sem cortá-lo por completo, vale a pena um corte
+perfurado. Os casos clássicos:
+
+- **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.
+
+## Ativar a perfuração
+
+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.
+
+
+
+## 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: 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 à 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/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..1be112fed
--- /dev/null
+++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md
@@ -0,0 +1,41 @@
+---
+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/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..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
@@ -7,19 +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. |
-| **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. |
+| **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 |
---
@@ -27,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?**
@@ -217,6 +227,56 @@ 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.
+
+---
+
+### 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
@@ -285,6 +345,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..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
@@ -7,21 +7,27 @@ 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`, `.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 |
+| **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
-| 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]
@@ -104,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.
+
+
+
+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
@@ -354,7 +406,19 @@ configurações de laser ajustáveis.
---
-## Importação de imagens raster (PNG, JPG, BMP)
+## 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/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..6b1bdb816 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.
@@ -57,6 +66,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.
+
+
+
+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:
+
+
+
+**Com a tabela de deslocamento aplicada:**
+
+
+
+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/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.
+
+
+
+- **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/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/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..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
@@ -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.md).
+
---
**Próximo**: [Visualização 3D →](3d-preview)
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/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-fonteRepositório GitHub
+
+
+ Regras de contribuição
+
+ CONTRIBUTING.md
+
Torne-se um Patrocinador
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..500b9aafc
--- /dev/null
+++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/addons/image-editing.md
@@ -0,0 +1,58 @@
+# Редагування зображень
+
+Розширення редагування зображень змінює вигляд зображення робочого елемента, не торкаючись
+імпортованого файлу. Кожне редагування зберігається разом із робочим елементом, тож його можна
+скасувати, прибрати пізніше або розмістити той самий файл кілька разів з різними редагуваннями.
+
+Полотно, 3D-перегляд і кожна операція використовують відредаговане зображення. Те, що ви бачите на
+полотні, те й гравіює лазер.
+
+## Інверсія зображення
+
+Виділіть один або кілька робочих елементів і виберіть **Tools - Invert Image**. Світлі області
+стають темними, а темні — світлими; прозорі області залишаються прозорими, тому тло, як і раніше, не
+гравіюється.
+
+
+
+
+
+Виберіть цей пункт ще раз, щоб прибрати інверсію. Для змішаного виділення інвертуються ті робочі
+елементи, які ще не інвертовані, тож усе виділення зрештою стає однаковим.
+
+Використовуйте це для літофаній і тиснення в операції гравіювання, де світлі області мають
+гравіюватися глибоко, або для гравіювання світлого зображення на темному тлі.
+
+## Коригування зображення
+
+Виберіть одну або кілька заготовок і виберіть **Tools - Adjust Image...**, щоб виправити фото перед
+гравіюванням або трасуванням:
+
+- **Brightness** (яскравість) освітлює або затемнює зображення.
+- **Contrast** (контраст) розводить тони від середньо-сірого або зводить їх до нього.
+- **Gamma** понад 1 освітлює середні тони, менше 1 затемнює їх; чорний і білий не змінюються.
+- **Sharpen** (різкість) підсилює краї та дрібні деталі (нерізке маскування). **Sharpen Radius**
+ задає розмір цих деталей у міліметрах, тож результат однаковий за будь-якого інтервалу ліній чи
+ масштабу.
+
+
+
+Полотно показує зміни під час редагування. **Apply** зберігає їх як один крок скасування; якщо
+закрити діалог без застосування, вони відкидаються, а **Reset** повертає всі значення до
+нейтральних. Прозорі ділянки залишаються прозорими.
+
+Діалог відкривається зі значеннями першої вибраної заготовки, а Apply надає всім вибраним заготовкам
+однакові значення. Різкість застосовується перед корекцією тонів, а інші редагування, як-от Invert
+Image, залишаються на своєму місці.
+
+## Старіші проєкти
+
+До цього розширення інверсія була налаштуванням операції гравіювання. Коли ви відкриваєте старіший
+проєкт, який його використовував, Rayforge переносить налаштування на робочі елементи цього шару,
+тож результат залишається тим самим.
+
+## Коли розширення вимкнено
+
+Проєкт зберігає свої редагування зображень, коли розширення вимкнено або не встановлено. Rayforge
+повідомляє, що проєкт використовує недоступні функції, і показує зображення без цих редагувань, доки
+розширення не буде знову увімкнено.
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-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/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..99030b9f1 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 @@
## Основні налаштування
-Діалогове вікно налаштувань кроку має три вкладки: **Налаштування кроку**, **Лазер** та
-**Постобробка**. Налаштування описані нижче у порядку вкладок.
+Діалогове вікно налаштувань кроку має чотири вкладки: **Налаштування кроку**, **Лазер**,
+**Постобробка** та **Робочі деталі**. Налаштування описані нижче у порядку вкладок.
### Налаштування контуру
@@ -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/operations/engrave.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/engrave.md
index c40e63e49..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).
**Діапазон яскравості:**
@@ -226,12 +224,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 +245,15 @@
**Дизеринг Байєра** створює регулярні патерни, які можуть давати художні ефекти, що нагадують
традиційний друк півтонами.
+**Stucki, Jarvis, Sierra, Burkes і Atkinson** — алгоритми дифузії помилки, як Floyd-Steinberg, що
+розподіляють помилку на ширше оточення. Це дає рівніше зерно на однорідних ділянках, наприклад на
+тонах шкіри. Atkinson розподіляє лише частину помилки, що підвищує контраст. Увімкніть **Змійкою**,
+щоб змінювати напрямок сканування в кожному рядку й уникати спрямованих смуг.
+
+**Газетний друк** і **Півтон** групують пікселі в точки. Півтон малює круглі точки на сітці, яку
+задають **Розмір комірки півтону** (відстань між центрами точок) і **Кут півтону**; обирайте розмір
+комірки щонайменше в кілька ширин лазерної плями.
+
#### Налаштування режиму кількох глибин

@@ -262,13 +276,11 @@
- Створює 3D-ефект, схожий на перехресну штриховку
- Типово: 0-45 градусів
-**Інверсія:**
+**Інверсія зображення:**
-- **Увімкнено:** Білий = глибоко, Чорний = мілко
-- **Вимкнено:** Чорний = глибоко, Білий = мілко
-
-Використовуйте інверсію для літофаній (світлі області мають бути тонкими) або тиснення (підняті
-області).
+Чорне гравіюється глибоко, а біле залишається мілким. Щоб поміняти їх місцями, для літофаній (світлі
+області мають бути тонкими) або тиснення (підняті області), виділіть робочий елемент і використайте
+**Tools - Invert Image**. Див. [Редагування зображень](../../addons/image-editing.md).
### Патерн сканування
@@ -320,7 +332,7 @@ Rayforge завжди сканує двонаправлено (зліва нап
двонаправленого сканування** нижче, щоб виправити це безпосередньо, а не втрачати перевагу у
швидкості.
-#### Зміщення двонаправленого сканування
+#### Зміщення двонаправленого сканування {#bidirectional-scan-offset}
Виправляє фіксоване механічне зміщення або зміщення затримки стрільби між растровими проходами зліва
направо та справа наліво, яке інакше роз'єднує альтернативні рядки сканування (видно як смугастість,
@@ -328,8 +340,9 @@ Rayforge завжди сканує двонаправлено (зліва нап
- Встановлюється в міліметрах, позитивне або негативне залежно від того, який напрямок потрібно
змістити
-- Застосовує постійне зміщення незалежно від швидкості; якщо зміщення залежить від швидкості,
- калібруйте для вашої типової швидкості гравіювання
+- Ненульове значення тут завжди має перевагу; коли воно дорівнює 0, крок звертається до таблиці
+ [Зміщення двонаправленого сканування за швидкістю](../../machine/advanced.md#bidirectional-scan-offset-by-speed)
+ машини, яка інтерполює зміщення для швидкості цього кроку
- За замовчуванням 0 (без корекції)
**Калібрування зміщення:**
@@ -340,6 +353,11 @@ Rayforge завжди сканує двонаправлено (зліва нап
3. Коригуйте зміщення малими кроками (0.01-0.05мм) і повторюйте тест, поки альтернативні рядки не
вирівняються
+Якщо невідповідність помітно змінюється між вашими звичайними швидкостями гравіювання, одне зміщення
+не виправить усі завдання. Залиште зміщення кроку на 0 і натомість введіть таблицю зміщень, що
+залежить від швидкості, у
+[Налаштування машини - Розширені](../../machine/advanced.md#bidirectional-scan-offset-by-speed).
+
## Налаштування лазера

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:
залежно від обраного режиму. Для режимів, які фіксують один із параметрів (наприклад, швидкість у
режимі «Потужність проти проходів»), тут також можна задати це константне значення.
+## Пов’язана робоча деталь
+
+Зазвичай операція обробляє всі робочі деталі свого шару, і вкладка **Робочі деталі** її діалогового
+вікна налаштувань перелічує їх усі як включені. Тестова сітка матеріалу є винятком: її операція
+пов’язана зі створеною нею робочою деталлю та обробляє лише її. На вкладці Робочі деталі створена
+сітка показана як єдина включена робоча деталь, а все інше на шарі позначено як виключене.
+
+
+
## Розуміння компонування
У режимі за замовчуванням «Потужність проти швидкості» потужність зростає зліва направо, а швидкість
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/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..a87fc20a9
--- /dev/null
+++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/path-cleanup.md
@@ -0,0 +1,50 @@
+# Очищення шляхів
+
+Логотипи й креслення з інших програм не завжди чисті. Контур може закінчуватися на частку міліметра
+раніше за свою початкову точку, фігура може складатися з кількох відкритих частин, або той самий
+шлях може бути у файлі двічі. Rayforge ріже такі шляхи саме такими, якими вони є: фігура, яка лише
+здається замкненою, вважається відкритою, а подвоєний шлях ріжеться двічі.
+
+Інструменти очищення шляхів виправляють це для вибраних робочих деталей. Вони є в меню **Об'єкт** і
+в контекстному меню полотна в розділі **Очистити шляхи**. Кожен інструмент — це один крок
+скасування.
+
+
+
+## Замкнути шляхи
+
+**Замкнути шляхи…** замикає кожен відкритий шлях, у якого початкова й кінцева точки ближчі за
+введений допуск. Проміжок з'єднується прямою лінією між двома наявними точками, тож фігура не
+зсувається і не змінює розміру.
+
+## З'єднати відкриті шляхи
+
+**З'єднати відкриті шляхи…** об'єднує в довші шляхи відкриті шляхи, кінцеві точки яких ближчі за
+допуск. За потреби частини розвертаються, тож порядок і напрямок, у якому їх намалювали, не мають
+значення. Якщо після цього кінці з'єднаного шляху збігаються, шлях також замикається.
+
+Обидва інструменти пам'ятають останній використаний допуск до кінця сеансу. Для більшості
+імпортованих файлів підходить допуск від 0,05 мм до 0,2 мм.
+
+## Видалити дублікати
+
+**Видалити дублікати** прибирає шляхи, що лежать точно на іншому шляху тієї самої робочої деталі,
+незалежно від їхнього напрямку чи початкової точки. Якщо вибрати кілька робочих деталей, то деталь,
+яка є точною копією іншої вибраної деталі в тому самому місці, також видаляється.
+
+Шляхи, що перекриваються лише частиною своєї довжини, залишаються. Ними опікується постпроцесор
+[Об'єднання ліній](./merge-lines.md) під час створення завдання.
+
+## Розібрати на частини
+
+**Розібрати на частини** робить із кожного шляху робочої деталі окрему деталь, включно з отворами й
+відкритими шляхами. Це відрізняється від **Розділити**, яке тримає острів разом з його отворами.
+
+Очищення шляхів працює лише з імпортованими та трасованими робочими деталями. Деталі, створені зі
+скетчу, редагуйте у [Sketcher](./sketcher/index.md).
+
+## Пов'язані сторінки
+
+- [Об'єднання ліній](./merge-lines.md) - Різати сегменти, що перекриваються, лише один раз
+- [Інструменти полотна](../ui/canvas-tools.md) - Вибір і видалення окремих сегментів
+- [Імпортування файлів](../files/importing.md) - Завантаження дизайнів у Rayforge
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..e04c31a1a
--- /dev/null
+++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/perforation.md
@@ -0,0 +1,65 @@
+---
+description:
+ "Різання пунктирних ліній у Rayforge за допомогою постобробки Перфорація: задайте довжину різу й
+ довжину пропуску для ліній згину, відривних ліній і отворів для шиття."
+---
+
+# Перфорація
+
+Не кожен різ має відділити деталь від матеріалу. Деякі лінії потрібні, щоб матеріал згинався вздовж
+них, чисто відривався чи показував, куди піде голка. Для цього існує постобробка **Перфорація**:
+вона перетворює суцільний різ на пунктирний. Замість того щоб тримати лазер увімкненим від початку
+до кінця, він різе на **Довжині різу**, проходить **Довжину пропуску** з вимкненим лазером і
+повторює цей ритм уздовж кожного контуру заготовки. У результаті лишається лінія з дрібних різів,
+яка тримає матеріал докупи — доти, доки ви не захочете його зігнути чи відрвати.
+
+
+
+## Типові застосування
+
+Скрізь, де лінія має послабити матеріал, не розрізаючи його, варто спробувати перфорований різ.
+Класичні приклади:
+
+- **Лінії згину** в картоні та гофрокартоні згинаються рівно вздовж пунктирного різу — коробки,
+ листівки та pop-up дизайни.
+- **Відривні лінії** дають змогу відкривати квитки, купони та упаковку без ножиць.
+- **Отвори для шиття** в шкірі позначають шлях голки ще до шиття.
+- **Пунктирні контури** самі собою є декоративним ефектом — на дереві, папері та акрилі.
+
+## Увімкнення перфорації
+
+Перфорація живе на сторінці **Постобробка** в налаштуваннях кроку [Контур](operations/contour),
+поруч з іншими постобробками. Типово вона вимкнена; щойно ви перемикаєте тумблер, з'являються два
+значення:
+
+- **Довжина різу** — як далеко лазер лишається увімкненим перед кожним проміжком.
+- **Довжина пропуску** — відстань, яку лазер проходить вимкненим між двома різами.
+
+Візерунок починається на початку кожного контуру з повного різу й поновлюється на кожному контурі,
+тож усі пунктирні лінії заготовки починаються однаково. Контур, замалий навіть для одного різу й
+одного пропуску, просто вирізається цілим.
+
+## Результат
+
+На полотні пунктирна лінія замінює суцільний контур різу — кожен проміжок у траєкторії буде
+проміжком і в готовій деталі.
+
+
+
+## Поради
+
+Для лінії згину, яка не повинна прорізати наскрізь, почніть із довжиною пропуску, приблизно рівною
+довжині різу, і зменште потужність — мілка канавка згинається так само добре, як глибокий пунктирний
+різ, а виворіт лишається чистим.
+
+Дуже малі довжини (кілька десятих міліметра) змушують лазер швидко вмикатися й вимикатися, що
+зменшує ефективну потужність і може лишати нерівні сліди. Якщо візерунок виглядає нерівномірно,
+спробуйте більші значення.
+
+Перфорація добре поєднується з рештою постобробки: [Утримуючі лапки](holding-tabs),
+[Вхід / Вихід](lead-in-out) і [Багатопрохідність](multi-pass) працюють як завжди.
+
+## Пов'язані сторінки
+
+- [Контурне різання](operations/contour) — операція різання, до якої належить ця постобробка
+- [Утримуючі лапки](holding-tabs) — проміжки, розставлені вручну, щоб деталі не випадали
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..f164d9981
--- /dev/null
+++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md
@@ -0,0 +1,40 @@
+---
+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/uk/docusaurus-plugin-content-docs/current/files/formats.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/files/formats.md
index e11526fc0..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
@@ -7,19 +7,25 @@
### Швидка довідка
-| Формат | Тип | Імпорт | Експорт | Рекомендоване використання |
-| ------------------- | --------- | ------------------ | ----------------- | ----------------------------- |
-| **SVG** | Вектор | ✓ Прямий / Трасув. | ✓ Експорт об'єкт. | Основний формат дизайну |
-| **DXF** | Вектор | ✓ Прямий | ✓ Експорт об'єкт. | Обмін даними CAD |
-| **PDF** | Змішаний | ✓ Прямий / Трасув. | – | Документи з векторним вмістом |
-| **LightBurn** | Вектор | ✓ Прямий | – | Проєкти LightBurn |
-| **Ruida** | Вектор | ✓ Прямий | – | Файли завдань Ruida |
-| **PNG** | Растр | ✓ Трасування | – | Фотографії, зображення |
-| **JPEG** | Растр | ✓ Трасування | – | Фотографії |
-| **BMP** | Растр | ✓ Трасування | – | Проста графіка |
-| **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** | Проєкт | ✓ | ✓ | Збер./завант. проєктів |
---
@@ -27,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?**
@@ -216,6 +226,54 @@ 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.
+
+---
+
+### 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 одиниць плотера на міліметр
+
+---
+
## Растрові формати
Усі растрові формати **імпортуються шляхом трасування** — автоматично конвертуються у векторні
@@ -284,6 +342,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..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
@@ -7,21 +7,27 @@ Rayforge підтримує імпортування різних файлови
### Векторні формати
-| Формат | Розширення | Метод імпорту | Найкраще для |
-| ------------- | ----------------- | ---------------------------- | ----------------------------------- |
-| **SVG** | `.svg` | Прямі вектори або трасування | Векторна графіка, логотипи, дизайни |
-| **DXF** | `.dxf` | Прямі вектори | CAD-креслення, технічні проекти |
-| **PDF** | `.pdf` | Прямі вектори або трасування | Документи з векторним вмістом |
-| **LightBurn** | `.lbrn`, `.lbrn2` | Прямі вектори | Проєкти LightBurn |
-| **Ruida** | `.rd` | Прямі вектори | Файли завдань контролера Ruida |
+| Формат | Розширення | Метод імпорту | Найкраще для |
+| --------------- | ----------------- | --------------------------------------------------- | ----------------------------------- |
+| **SVG** | `.svg`, `.svgz` | Прямі вектори або трасування | Векторна графіка, логотипи, дизайни |
+| **DXF** | `.dxf` | Прямі вектори | CAD-креслення, технічні проекти |
+| **PDF** | `.pdf` | Прямі вектори або трасування | Документи з векторним вмістом |
+| **LightBurn** | `.lbrn`, `.lbrn2` | Прямі вектори | Проєкти LightBurn |
+| **Ruida** | `.rd` | Прямі вектори | Файли завдань контролера Ruida |
+| **Illustrator** | `.ai` | Прямі вектори або трасування | Логотипи з Illustrator |
+| **EPS** | `.eps`, `.ps` | Прямі вектори або трасування (потрібен Ghostscript) | Логотипи, макети для друку |
+| **HPGL** | `.plt`, `.hpgl` | Прямі вектори | Файли плотерів і вінілових катерів |
### Растрові формати
| Формат | Розширення | Метод імпорту | Найкраще для |
| -------- | --------------- | -------------------- | --------------------------------------------- |
-| **PNG** | `.webp` | Трасування у вектори | Фотографії, зображення з прозорістю |
+| **PNG** | `.png` | Трасування у вектори | Фотографії, зображення з прозорістю |
| **JPEG** | `.jpg`, `.jpeg` | Трасування у вектори | Фотографії, зображення з безперервними тонами |
| **BMP** | `.bmp` | Трасування у вектори | Прості графіки, знімки екрана |
+| **GIF** | `.gif` | Трасування у вектори | Проста графіка, анімації |
+| **TIFF** | `.tif`, `.tiff` | Трасування у вектори | Скани, макети для друку |
+| **WebP** | `.webp` | Трасування у вектори | Вебзображення |
:::note Імпортування растрових зображень :::
@@ -101,6 +107,49 @@ rayforge file1.svg file2.dxf
Це гарантує, що ваші дизайни завжди вміщуються в межах можливостей вашої машини, надаючи вам
гнучкість для відновлення оригінального розміру за необхідності.
+### Синхронізація імпортованих файлів
+
+Під час імпортування Rayforge зберігає посилання на файл на диску. Якщо файл пізніше змінюється —
+наприклад, після редагування та повторного збереження в Inkscape або у вашій CAD-програмі — Rayforge
+пропонує перезавантажити імпортований вміст, щоб ваш проект не зберігав застарілу геометрію.
+
+
+
+Це керується опцією **Стежити за імпортованими файлами** у Налаштування → Загальні. Коли вона
+увімкнена (за замовчуванням), Rayforge перевіряє пов'язані файли, поки проект відкритий — зокрема
+одразу після його відкриття — і показує діалог для кожного файлу, що змінився на диску:
+
+- **Перезавантажити** замінює імпортований вміст новою версією. Положення, розмір і налаштування
+ задіяних робочих деталей зберігаються, а все перезавантаження є одним кроком скасування.
+- **Ігнорувати** зберігає поточний вміст. Вас запитають знову наступного разу, коли файл зміниться.
+- Увімкніть **Завжди перезавантажувати цей файл без запитування**, щоб цей файл надалі
+ перезавантажувався автоматично. Позначені так файли перезавантажуються без запитування.
+
+Коли кілька пов'язаних файлів змінилися одночасно, діалог перелічує їх і дозволяє вибрати, які з них
+перезавантажити.
+
+
+:::note[Перезавантаження пристосовує документ]
+Перезавантаження приводить документ у відповідність до файлу: елементи, додані до нього, з'являються
+в проекті, елементи, яких у ньому більше немає, видаляються, а утримуючі лапки, що опинилися далі
+ніж 1 мм від нового контуру, відкидаються. Елементи, які ви редагували в Rayforge, залишаються
+без змін. Сповіщення підсумовує, що змінило перезавантаження.
+:::
+
+
+### Ручне перезавантаження та повторне прив'язування
+
+Ви також можете вручну перезавантажити або переприв'язати будь-коли через **Файл → Перезавантажити з
+диска** та **Файл → Переприв'язати вихідний файл…**, або через контекстне меню виділення на полотні,
+списку шарів чи браузері активів. **Перезавантажити з диска** повторно читає файли за виділеними
+робочими деталями. Коли пов'язаний файл неможливо знайти, Rayforge натомість пропонує переприв'язати
+його.
+
+**Переприв'язати вихідний файл…** вказує імпортованому джерелу на інший файл. Це корисно, коли файл
+було переміщено або перейменовано, або коли проект відкривається на іншому комп'ютері, а пов'язаного
+файлу там немає. Робочі деталі зберігають своє положення та налаштування і показують вміст нового
+файлу.
+
---
## Імпортування SVG
@@ -344,7 +393,18 @@ PDF, де текст не перетворено на контури — Rayforg
---
-## Імпортування растрових зображень (PNG, JPG, BMP)
+## Імпорт 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/uk/docusaurus-plugin-content-docs/current/machine/advanced.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/machine/advanced.md
index 7c00414b5..3a37aabe0 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.
@@ -55,6 +64,41 @@
Типові значення варіюються від 0.01мм для точної роботи до 0.1мм для швидшої обробки.
+## Зміщення двонаправленого сканування за швидкістю {#bidirectional-scan-offset-by-speed}
+
+Таблиця зміщення двонаправленого сканування за швидкістю виправляє горизонтальне невідповідність між
+растровими проходами зліва направо та справа наліво. На багатьох машинах це зміщення не є сталим:
+чим швидше рухається лазерна головка, тим сильніше проходи справа наліво відхиляються від
+вирівнювання, що проявляється як смугастість, яка гіршає на вищих швидкостях гравіювання.
+
+
+
+Кожен рядок таблиці поєднує швидкість гравіювання зі зміщенням у міліметрах, якого проходи справа
+наліво потребують на цій швидкості:
+
+- Використовуйте **Додати Рядок**, щоб додати рядок, і кнопку кошика на рядку, щоб видалити його
+- Позитивні значення зміщують проходи справа наліво вправо, негативні — вліво
+- Рядки зберігаються відсортованими за швидкістю; якщо два рядки мають однакову швидкість, чинним є
+ останній
+
+Коли крок гравіювання виконується, Rayforge шукає швидкість кроку в цій таблиці та лінійно
+інтерполює зміщення між сусідніми рядками. Швидкості нижче першого рядка або вище останнього
+використовують зміщення найближчого рядка, а порожня таблиця означає, що всі швидкості
+використовують зміщення 0.
+
+**Без корекції** — невідповідність зростає зі швидкістю гравіювання:
+
+
+
+**Із застосованою таблицею зміщень:**
+
+
+
+Таблиця застосовується лише до кроків гравіювання, власне **Зміщення двонаправленого сканування**
+яких дорівнює 0; ненульове зміщення кроку завжди має перевагу. Дивіться
+[Зміщення двонаправленого сканування](../features/operations/engrave.md#bidirectional-scan-offset),
+щоб побачити, як виглядає невідповідність і як її калібрувати.
+
## Дивіться також
- [Налаштування обладнання](hardware) - Конфігурація початку осей та інверсії
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.
---
+### Вигляд поза робочою областю
+
+Камера зазвичай бачить більше, ніж лише робочу область. У Налаштуваннях машини → Камера → Вигляд
+поза робочою областю можна показати додаткову напівпрозору рамку з зображення камери навколо робочої
+області, що полегшує позиціонування заготовок.
+
+
+
+- **Показувати поза робочою областю** вмикає або вимикає рамку. Типово вона вимкнена та потребує
+ вирівняної камери.
+- **Поле** визначає, наскільки рамка виходить за межі робочої області, до 100 мм.
+- **Прозорість** визначає помітність рамки.
+
+Ділянки, які камера не бачить, залишаються прозорими замість чорних, а рамка закінчується на межі
+того, що камера фактично охоплює. Об'єкти, що виступають над столом, як-от портал, можуть виглядати
+зміщеними в рамці, оскільки зображення камери вирівняне до площини столу.
+
+---
+
### Кілька камер
Rayforge підтримує кілька камер для різних видів або машин:
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/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..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
@@ -103,6 +103,9 @@
Щоб вийти з режиму редагування векторів, клацніть поза робочою деталлю або натисніть
Escape.
+Щоб закрити проміжки, з'єднати відкриті частини, видалити дублікати шляхів або розібрати робочу
+деталь на частини, скористайтеся інструментами [Очищення шляхів](../features/path-cleanup.md).
+
---
**Далі**: [3D вид →](3d-preview)
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/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/addons/image-editing.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/addons/image-editing.md
new file mode 100644
index 000000000..cc8397770
--- /dev/null
+++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/addons/image-editing.md
@@ -0,0 +1,44 @@
+# 图像编辑
+
+图像编辑插件可以在不改动导入文件的情况下更改工件图像的外观。每次编辑都与工件一起保存,因此您可以撤销它、稍后移除它,或者将同一文件以不同的编辑放置多次。
+
+画布、3D 预览和每个操作都使用编辑后的图像。您在画布上看到的就是激光雕刻的内容。
+
+## 反转图像
+
+选择一个或多个工件,然后选择 **Tools - Invert
+Image**。浅色区域变深,深色区域变浅;透明区域保持透明,因此背景仍然不会被雕刻。
+
+
+
+
+
+再次选择它即可取消反转。对于混合选择,尚未反转的工件会被反转,使整个选择最终保持一致。
+
+在雕刻操作中将其用于石版画和浮雕(浅色区域应雕刻得较深),或者在深色背景上雕刻浅色图案。
+
+## 调整图像
+
+选择一个或多个工件,然后选择 **Tools - Adjust Image...**,在雕刻或描摹之前校正照片:
+
+- **Brightness**(亮度)使图像变亮或变暗。
+- **Contrast**(对比度)使色调远离或靠近中灰。
+- **Gamma** 大于 1 时提亮中间调,小于 1 时压暗中间调;黑色和白色保持不变。
+- **Sharpen**(锐化)增强边缘和细节(USM 锐化)。**Sharpen Radius**
+ 以毫米为单位设置这些细节的大小,因此无论行间距或缩放如何,结果都相同。
+
+
+
+编辑时画布会实时显示更改。**Apply** 将其作为一个撤销步骤保留;不应用就关闭对话框会放弃更改,
+**Reset** 将所有值恢复为中性。透明区域保持透明。
+
+对话框打开时显示第一个所选工件的值,Apply 会为所有所选工件设置相同的值。锐化在色调校正之前进行,Invert
+Image 等其他编辑保持原位。
+
+## 旧项目
+
+在此插件出现之前,反转是雕刻操作的一个设置。当您打开使用过该设置的旧项目时,Rayforge 会将该设置移动到该图层的工件上,因此输出保持不变。
+
+## 插件被禁用时
+
+当插件被禁用或未安装时,项目会保留其图像编辑。Rayforge 会提示项目使用了不可用的功能,并在插件重新启用之前显示未应用这些编辑的图像。
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-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方向。关闭该开关即可只描绘激光实际烧灼的轮廓。
+
### 方法二:低功率预览
部分机器支持低功率框定,带有可见光束:
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..4d912dca5 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 @@
## 关键设置
-步骤设置对话框包含三个选项卡:**步骤设置**、**激光**和**后处理**。设置按选项卡顺序说明如下。
+步骤设置对话框包含四个选项卡:**步骤设置**、**激光**、**后处理**和**工件**。设置按选项卡顺序说明如下。
### 轮廓设置
@@ -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/operations/engrave.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/engrave.md
index 7011da6c1..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)。
**亮度范围:**
@@ -211,18 +209,30 @@
选择最适合您的图像和材料的算法:
-| 算法 | 质量 | 速度 | 最适用于 |
-| --------------- | ---- | ---- | -------------------- |
-| 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 只扩散部分误差,因此对比度更高。启用**蛇形扫描**可在每一行交替扫描方向,避免方向性条纹。
+
+**报纸印刷**和**半色调**会把像素聚集成网点。半色调在网格上绘制圆形网点,网格由**半色调网格尺寸**(网点中心之间的距离)和**半色调角度**决定;网格尺寸应至少为激光光斑宽度的几倍。
+
#### 多深度模式设置

@@ -245,12 +255,10 @@
- 创建类似交叉阴影的 3D 效果
- 典型:0-45 度
-**反转:**
+**反转图像:**
-- **启用:** 白色 = 深,黑色 = 浅
-- **禁用:** 黑色 = 深,白色 = 浅
-
-反转用于石版画(浅色区域应较薄)或浮雕(凸起区域)。
+黑色雕刻得深,白色保持浅。要交换两者(例如石版画的浅色区域应较薄,或浮雕的凸起区域),请选择工件并使用
+**Tools - Invert Image**。参见[图像编辑](../../addons/image-editing.md)。
### 扫描图案
@@ -299,12 +307,14 @@ Rayforge 始终进行双向扫描(从左到右,然后从右到左),因
两个方向之间轻微的机械或发射延迟差异可能会在某些机器上造成可见的条带。如果出现这种情况,请校准下面的**双向扫描偏移**直接进行修正,而不是失去速度优势。
-#### 双向扫描偏移
+#### 双向扫描偏移 {#bidirectional-scan-offset}
修正从左到右与从右到左光栅遍之间的固定机械或发射延迟偏斜,否则会使交替的扫描行错位(显示为条带,尤其是在照片雕刻中)。
- 以毫米为单位设置,正值或负值取决于需要移动的方向
-- 无论速度如何均施加恒定的偏移;如果偏斜随速度变化,请以您的典型雕刻速度进行校准
+- 此处非零的值始终优先;当其为 0 时,步骤会退回到机器的
+ [按速度设置的双向扫描偏移](../../machine/advanced.md#bidirectional-scan-offset-by-speed)
+ 表,该表会为此步骤的速度插值出一个偏移
- 默认为 0(不修正)
**校准偏移:**
@@ -313,6 +323,10 @@ Rayforge 始终进行双向扫描(从左到右,然后从右到左),因
2. 比较交替行以确定错位的方向和程度
3. 以小增量(0.01-0.05mm)调整偏移并重新测试,直到交替行对齐
+如果错位在您的常用雕刻速度之间变化明显,单一偏移无法修正所有作业。请将步骤偏移保持为 0,改为在
+[机器设置 - 高级](../../machine/advanced.md#bidirectional-scan-offset-by-speed)
+中填写随速度变化的偏移表。
+
## 激光设置

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),速度值会以您偏好的显示单位显示。
**参数**
部分保存网格要变化的范围——速度、功率、通过次数或偏移量,取决于所选模式。固定某个参数的模式(例如功率与通过次数中的速度)也允许您在此处设置该常量。
+## 关联的工件
+
+通常,一个操作会处理其图层上的所有工件,其设置对话框的**工件**选项卡会将它们全部列为包含。材料测试网格是个例外:它的操作与它生成的工件绑定,只处理该工件。在工件选项卡中,生成的网格是唯一被包含的工件,图层上的其他工件都被标记为排除。
+
+
+
## 理解布局
在默认的功率与速度模式下,功率从左到右增加,速度从上到下增加:
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/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..0bd793333
--- /dev/null
+++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/path-cleanup.md
@@ -0,0 +1,36 @@
+# 清理路径
+
+来自其他软件的标志和图纸并不总是干净的。轮廓可能在距起点不到一毫米的地方就停止了,一个形状可能由多段开放的片段组成,或者同一条路径在文件中出现了两次。Rayforge 会按原样切割这些路径:看起来闭合但实际未闭合的形状会被当作开放路径处理,重复的路径会被切割两次。
+
+路径清理工具可以修复所选工件中的这些问题。您可以在**对象**菜单中,以及画布右键菜单的**清理路径**下找到它们。每个工具都是一个撤销步骤。
+
+
+
+## 闭合路径
+
+**闭合路径…**会闭合所有起点与终点距离小于您输入的公差的开放路径。缺口由两个现有点之间的一条直线连接,因此形状不会移动,也不会改变大小。
+
+## 连接开放路径
+
+**连接开放路径…**会把端点距离小于公差的开放路径连接成更长的路径。必要时会反转片段方向,因此片段的绘制顺序和方向都无关紧要。如果连接后的路径首尾相接,该路径也会被闭合。
+
+两个工具都会在本次会话中记住您上次使用的公差。对大多数导入的文件来说,0.05 mm 到 0.2
+mm 之间的公差即可。
+
+## 删除重复项
+
+**删除重复项**会删除与同一工件中另一条路径完全重合的路径,无论其方向或起点如何。如果您选择了多个工件,与另一个所选工件位置相同且完全相同的工件也会被删除。
+
+只有部分长度重叠的路径会被保留。生成作业时,[合并线条](./merge-lines.md)后处理器会处理这些路径。
+
+## 打散
+
+**打散**会把工件的每条路径都变成一个独立的工件,包括孔和开放路径。这与**分割**不同,分割会把一个岛与它的孔保留在一起。
+
+路径清理只适用于导入和描摹得到的工件。来自草图的工件请在[草图编辑器](./sketcher/index.md)中编辑。
+
+## 相关页面
+
+- [合并线条](./merge-lines.md) - 重叠的线段只切割一次
+- [画布工具](../ui/canvas-tools.md) - 选择和删除单个线段
+- [导入文件](../files/importing.md) - 将设计导入 Rayforge
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..58e32fb8a
--- /dev/null
+++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/perforation.md
@@ -0,0 +1,47 @@
+---
+description: "使用穿孔后处理器在 Rayforge 中切割虚线:为折线、撕线和缝线孔设置切割长度和跳过长度。"
+---
+
+# 穿孔
+
+并不是每条切割线都是为了把零件从材料上取下来。有些线的意义在于让材料沿着它折叠、干净地撕开,或预先标出缝针的走向。**穿孔**后处理正是为此而生:它把连续切割变成虚线切割。激光不再从头到尾一直出光,而是按**切割长度**出光,再关闭激光走过**跳过长度**,并沿工件的每条轮廓重复这一节奏。留下的是一串小切割,它们把材料连在一起——直到你想让它折或撕开的那一刻。
+
+
+
+## 典型用途
+
+凡是需要削弱材料而又不把它切开的场合,都值得考虑穿孔切割。常见的例子:
+
+- 卡纸和瓦楞纸板上的**折线**沿虚线切口干净利落地折叠——比如盒子、卡片和立体弹出设计。
+- **撕线**让门票、优惠券和包装不需要剪刀就能打开。
+- 皮革上的**缝线孔**在缝纫前标出针的走向。
+- **虚线轮廓**本身就是一种装饰效果,适合木材、纸张和亚克力。
+
+## 启用穿孔
+
+穿孔位于[轮廓](operations/contour)步骤设置中的**后处理**页面,与其他后处理并列。它默认关闭;打开开关后会出现两个数值:
+
+- **切割长度**:每个间隙前激光持续出光的距离。
+- **跳过长度**:两段切割之间关闭激光移动的距离。
+
+图案从每条轮廓的起点的一段完整切割开始,并在每条轮廓上重新开始,因此工件上所有虚线都以同样的方式起头。短到连一段切割加一段跳跃都放不下的轮廓会被完整切下。
+
+## 效果
+
+在画布上,虚线取代了实线切割轮廓——刀具路径上的每个间隙就是成品零件上的每个间隙。
+
+
+
+## 提示
+
+对于不应切穿的折线,可以先让跳过长度与切割长度大致相同,并降低功率——浅浅的压痕和深深的虚线切口一样容易折,而且背面保持整洁。
+
+非常短的长度(零点几毫米)会让激光快速开关,降低有效功率,还可能留下不均匀的痕迹。如果图案看起来不一致,试试更长的数值。
+
+穿孔与后处理的其他功能配合良好:[固定标签](holding-tabs)、[引入 / 引出](lead-in-out)和
+[多次遍](multi-pass)都照常工作。
+
+## 相关页面
+
+- [轮廓切割](operations/contour) — 本后处理所属的切割操作
+- [固定标签](holding-tabs) — 手动放置以固定零件的间隙
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..6a77eb389
--- /dev/null
+++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/sketcher/polygon.md
@@ -0,0 +1,27 @@
+---
+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/files/formats.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/files/formats.md
index 77959879a..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
@@ -6,19 +6,25 @@
### 快速参考
-| 格式 | 类型 | 导入 | 导出 | 推荐用途 |
-| ----------------- | ---- | ----------- | ---------- | -------------------- |
-| **SVG** | 矢量 | ✓ 直接/描摹 | ✓ 对象导出 | 主要设计格式 |
-| **DXF** | 矢量 | ✓ 直接 | ✓ 对象导出 | CAD 数据交换 |
-| **PDF** | 混合 | ✓ 直接/描摹 | – | 包含矢量内容的文档 |
-| **LightBurn** | 矢量 | ✓ 直接 | – | LightBurn 项目文件 |
-| **Ruida** | 矢量 | ✓ 直接 | – | Ruida 控制器作业文件 |
-| **PNG** | 栅格 | ✓ 描摹 | – | 照片、图像 |
-| **JPEG** | 栅格 | ✓ 描摹 | – | 照片 |
-| **BMP** | 栅格 | ✓ 描摹 | – | 简单图形 |
-| **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 项目** | 项目 | ✓ | ✓ | 保存/加载项目 |
---
@@ -26,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?**
@@ -207,6 +215,44 @@ 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 的电脑上也能打开。
+
+---
+
+### 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 个绘图仪单位
+
+---
+
## 栅格格式
所有栅格格式均**通过描摹导入** — 自动转换为矢量路径。
@@ -271,6 +317,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..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
@@ -6,21 +6,27 @@ Rayforge 支持导入多种文件格式,包括矢量格式和光栅格式。
### 矢量格式
-| 格式 | 扩展名 | 导入方式 | 适用场景 |
-| ------------- | ----------------- | -------------- | -------------------- |
-| **SVG** | `.svg` | 直接矢量或描摹 | 矢量图形、标志、设计 |
-| **DXF** | `.dxf` | 直接矢量 | CAD 绘图、技术设计 |
-| **PDF** | `.pdf` | 直接矢量或描摹 | 包含矢量内容的文档 |
-| **LightBurn** | `.lbrn`, `.lbrn2` | 直接矢量 | LightBurn 项目文件 |
-| **Ruida** | `.rd` | 直接矢量 | Ruida 控制器作业文件 |
+| 格式 | 扩展名 | 导入方式 | 适用场景 |
+| --------------- | ----------------- | ---------------------------------- | ----------------------- |
+| **SVG** | `.svg`, `.svgz` | 直接矢量或描摹 | 矢量图形、标志、设计 |
+| **DXF** | `.dxf` | 直接矢量 | CAD 绘图、技术设计 |
+| **PDF** | `.pdf` | 直接矢量或描摹 | 包含矢量内容的文档 |
+| **LightBurn** | `.lbrn`, `.lbrn2` | 直接矢量 | LightBurn 项目文件 |
+| **Ruida** | `.rd` | 直接矢量 | Ruida 控制器作业文件 |
+| **Illustrator** | `.ai` | 直接矢量或描摹 | 来自 Illustrator 的标志 |
+| **EPS** | `.eps`, `.ps` | 直接矢量或描摹(需要 Ghostscript) | 标志、印刷稿 |
+| **HPGL** | `.plt`, `.hpgl` | 直接矢量 | 绘图仪和刻字机文件 |
### 光栅格式
| 格式 | 扩展名 | 导入方式 | 适用场景 |
| -------- | --------------- | ---------- | -------------------- |
-| **PNG** | `.webp` | 描摹为矢量 | 照片、带透明度的图像 |
+| **PNG** | `.png` | 描摹为矢量 | 照片、带透明度的图像 |
| **JPEG** | `.jpg`, `.jpeg` | 描摹为矢量 | 照片、连续色调图像 |
| **BMP** | `.bmp` | 描摹为矢量 | 简单图形、截图 |
+| **GIF** | `.gif` | 描摹为矢量 | 简单图形、动画 |
+| **TIFF** | `.tif`, `.tiff` | 描摹为矢量 | 扫描件、印刷稿 |
+| **WebP** | `.webp` | 描摹为矢量 | 网页图片 |
:::note[光栅图像导入]
@@ -94,6 +100,38 @@ rayforge file1.svg file2.dxf
这确保您的设计始终适应机器的能力范围,同时在需要时可以恢复原始尺寸。
+### 保持导入文件同步
+
+导入文件时,Rayforge 会保留指向磁盘上该文件的链接。如果文件之后发生了变化——例如在 Inkscape 或您的 CAD 程序中编辑并重新保存后——Rayforge 会提议重新加载导入的内容,以免您的项目保留过时的几何图形。
+
+
+
+此行为由 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 导入
@@ -321,7 +359,16 @@ LightBurn(.lbrn /
---
-## 光栅图像导入(PNG、JPG、BMP)
+## 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/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..a0e363467 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 移动的设置。
@@ -46,6 +53,30 @@
典型值从精密工作的 0.01mm 到更快处理的 0.1mm。
+## 按速度设置的双向扫描偏移 {#bidirectional-scan-offset-by-speed}
+
+按速度设置的双向扫描偏移表用于修正从左到右与从右到左光栅遍之间的水平错位。在许多机器上,这种偏斜并不是恒定的:激光头移动得越快,从右到左的遍就偏离对齐得越多,表现为条带,并且雕刻速度越高越严重。
+
+
+
+表格的每一行将一个雕刻速度与从右到左的遍在该速度下所需的偏移(以毫米为单位)配对:
+
+- 使用**添加行**添加一行,点击行上的垃圾桶按钮将其删除
+- 正值将从右到左的遍向右移动,负值向左移动
+- 行会按速度保持排序;如果两行的速度相同,以最后一行为准
+
+当雕刻步骤运行时,Rayforge 会在该表中查找步骤的速度,并在相邻行之间线性插值偏移。低于第一行或高于最后一行的速度使用最近一行的偏移,空表表示所有速度都使用 0 偏移。
+
+**未修正** —— 错位随雕刻速度增大:
+
+
+
+**应用偏移表后:**
+
+
+
+该表仅适用于自身**双向扫描偏移**为 0 的雕刻步骤;非零的步骤级偏移始终优先。关于错位的外观以及如何校准,请参阅[双向扫描偏移](../features/operations/engrave.md#bidirectional-scan-offset)。
+
## 另请参阅
- [硬件设置](hardware) - 轴原点和翻转设置
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 默认推荐交错纸,因此如果您的纸是普通矩形排列,
---
+### 工作区域之外的视图
+
+相机通常能看到工作区域以外的内容。在 机器设置 → 相机 → 工作区域之外的视图 中,可以在工作区域周围显示一圈额外的半透明相机图像边框,便于放置工件。
+
+
+
+- **显示工作区域之外** 用于开关该边框。默认关闭,且需要已完成对齐的相机。
+- **边距** 控制边框超出工作区域的距离,最大 100 毫米。
+- **透明度** 控制边框的可见程度。
+
+相机看不到的区域保持透明而不是显示为黑色,边框也会在相机实际覆盖范围的边缘处截止。高于床面的物体(如横梁)在边框中可能出现偏移,因为相机图像是对齐到床面平面的。
+
+---
+
### 多相机
Rayforge 支持多个相机用于不同视图或机器:
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
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..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,6 +159,8 @@
要退出矢量编辑模式,请点击工件外部或按 Escape。
+要闭合缺口、连接开放的片段、删除重复路径或将工件打散,请使用[清理路径](../features/path-cleanup.md)工具。
+
---
**下一步:**[3D 预览 →](3d-preview)
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/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/sidebars.js b/website/sidebars.js
index c1577ae78..52e3e43bd 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',
],
@@ -91,6 +92,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',
@@ -102,6 +104,7 @@ module.exports = {
],
},
'features/arrays',
+ 'features/path-cleanup',
'features/multi-layer',
'features/stock-handling',
'features/framing-your-job',
@@ -116,6 +119,7 @@ module.exports = {
items: [
'addons/ai-workpiece-generator',
'addons/deepnest',
+ 'addons/image-editing',
'addons/print-and-cut',
'addons/projector-mode',
'addons/smart-stock',
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;
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 sourceGitHub repository
+
+
+ Contribution rules
+
+ CONTRIBUTING.md
+
Become a Sponsor
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 000000000..9017c7fac
Binary files /dev/null and b/website/static/images/bidir-scan-offset-table-applied.webp differ
diff --git a/website/static/images/bidir-scan-offset-uncorrected.webp b/website/static/images/bidir-scan-offset-uncorrected.webp
new file mode 100644
index 000000000..d90b2a224
Binary files /dev/null and b/website/static/images/bidir-scan-offset-uncorrected.webp differ
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 000000000..da29caed9
Binary files /dev/null and b/website/static/images/image-edit-invert-after.webp differ
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 000000000..62c97248d
Binary files /dev/null and b/website/static/images/image-edit-invert-before.webp differ
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 000000000..44d899796
Binary files /dev/null and b/website/static/screenshots/addon-image-editing-adjust.webp differ
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 000000000..2d8e6aad2
Binary files /dev/null and b/website/static/screenshots/addons-sketcher-tool-polygon.webp differ
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 000000000..dc25627d8
Binary files /dev/null and b/website/static/screenshots/machine-settings-advanced-bidir-offset-table.webp differ
diff --git a/website/static/screenshots/machine-settings-advanced.webp b/website/static/screenshots/machine-settings-advanced.webp
index 2fbd852f8..d49d59480 100644
Binary files a/website/static/screenshots/machine-settings-advanced.webp and b/website/static/screenshots/machine-settings-advanced.webp differ
diff --git a/website/static/screenshots/machine-settings-camera.webp b/website/static/screenshots/machine-settings-camera.webp
index a4a0be6e9..18a479bd1 100644
Binary files a/website/static/screenshots/machine-settings-camera.webp and b/website/static/screenshots/machine-settings-camera.webp differ
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 000000000..62ed76f90
Binary files /dev/null and b/website/static/screenshots/main-camera-outside-view.webp differ
diff --git a/website/static/screenshots/main-path-cleanup.webp b/website/static/screenshots/main-path-cleanup.webp
new file mode 100644
index 000000000..85a6a0610
Binary files /dev/null and b/website/static/screenshots/main-path-cleanup.webp differ
diff --git a/website/static/screenshots/operations-perforation.webp b/website/static/screenshots/operations-perforation.webp
new file mode 100644
index 000000000..2dc331705
Binary files /dev/null and b/website/static/screenshots/operations-perforation.webp differ
diff --git a/website/static/screenshots/source-changed-dialog.webp b/website/static/screenshots/source-changed-dialog.webp
new file mode 100644
index 000000000..d1cbbeed8
Binary files /dev/null and b/website/static/screenshots/source-changed-dialog.webp differ
diff --git a/website/static/screenshots/step-settings-contour-post-perforation.webp b/website/static/screenshots/step-settings-contour-post-perforation.webp
new file mode 100644
index 000000000..8fa88c218
Binary files /dev/null and b/website/static/screenshots/step-settings-contour-post-perforation.webp differ
diff --git a/website/static/screenshots/step-settings-contour-post.webp b/website/static/screenshots/step-settings-contour-post.webp
index cd4621a9b..b32d952a1 100644
Binary files a/website/static/screenshots/step-settings-contour-post.webp and b/website/static/screenshots/step-settings-contour-post.webp differ
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 6f8b0a6f2..39a09a7f7 100644
Binary files a/website/static/screenshots/step-settings-engrave-general-constant_power.webp and b/website/static/screenshots/step-settings-engrave-general-constant_power.webp differ
diff --git a/website/static/screenshots/step-settings-engrave-general-dither.webp b/website/static/screenshots/step-settings-engrave-general-dither.webp
index 5352aacf6..657a1b187 100644
Binary files a/website/static/screenshots/step-settings-engrave-general-dither.webp and b/website/static/screenshots/step-settings-engrave-general-dither.webp differ
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 f759102ae..c4889cb2a 100644
Binary files a/website/static/screenshots/step-settings-engrave-general-multi_pass.webp and b/website/static/screenshots/step-settings-engrave-general-multi_pass.webp differ
diff --git a/website/static/screenshots/step-settings-engrave-general-variable.webp b/website/static/screenshots/step-settings-engrave-general-variable.webp
index 2b14f4536..39e2b6ff4 100644
Binary files a/website/static/screenshots/step-settings-engrave-general-variable.webp and b/website/static/screenshots/step-settings-engrave-general-variable.webp differ
diff --git a/website/static/screenshots/step-settings-engrave-post.webp b/website/static/screenshots/step-settings-engrave-post.webp
index a5a1254a5..5df9cbea5 100644
Binary files a/website/static/screenshots/step-settings-engrave-post.webp and b/website/static/screenshots/step-settings-engrave-post.webp differ
diff --git a/website/static/screenshots/step-settings-material-test-grid-workpieces.webp b/website/static/screenshots/step-settings-material-test-grid-workpieces.webp
new file mode 100644
index 000000000..22202f026
Binary files /dev/null and b/website/static/screenshots/step-settings-material-test-grid-workpieces.webp differ